@vibeorm/extensions 2.2.1 → 2.3.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
@@ -37,9 +37,9 @@ Each extension declares its schema attributes, the DDL artifacts `db push` / `mi
37
37
 
38
38
  ## Search
39
39
 
40
- Declare the searched fields with `@@search`. Optional named arguments: `name` (artifact base name, a plain identifier), `weights` (one finite number per field, **tsvector only**), `language` (a text-search config name, tsvector engine only).
40
+ Declare the searched fields with `@@search`. Optional named arguments: `name` (artifact base name, a plain identifier), `weights` (one finite number per field, tsvector and bm25 engines), `language` (a text-search config name, tsvector engine only), plus the [bm25 index options](#paradedb-bm25-index-options) below.
41
41
 
42
- Weights express ordinal priority, not numeric multipliers: distinct values descending map to PostgreSQL's A, B, C, D classes, with further classes capped at D. Equal weights share a class. FTS5, MySQL FULLTEXT and ParadeDB BM25 refuse declared weights with `VIBE_UNSUPPORTED_CAPABILITY`; omit `weights` on those engines.
42
+ On tsvector, weights express ordinal priority, not numeric multipliers: distinct values descending map to PostgreSQL's A, B, C, D classes, with further classes capped at D. Equal weights share a class. On bm25 they are used verbatim as per-field `pdb.boost` factors (multiplicative, within ±2048; `1` emits nothing). FTS5 and MySQL FULLTEXT refuse declared weights with `VIBE_UNSUPPORTED_CAPABILITY`; omit `weights` on those engines.
43
43
 
44
44
  ```prisma
45
45
  model Post {
@@ -80,7 +80,7 @@ const hits = await db.post.search({
80
80
  | Dialect | Engine | SQL used | Highlights |
81
81
  |---|---|---|---|
82
82
  | postgres / pglite | `tsvector` | generated `STORED` tsvector column + GIN index, `websearch_to_tsquery` / `ts_rank_cd` / `ts_headline` | yes |
83
- | postgres (ParadeDB `pg_search`, opt in with `search({ engine: "bm25" })`) | `bm25` | bm25 index, `@@@`, `paradedb.score` / `paradedb.snippet` | yes |
83
+ | postgres (ParadeDB `pg_search`, opt in with `search({ engine: "bm25" })`) | `bm25` | bm25 index with declared tokenizer casts, `@@@` (or `&&&` with `pdb.fuzzy` for prefix/fuzzy), `pdb.boost` per field, `paradedb.score` / `paradedb.snippet` | yes |
84
84
  | sqlite | `fts5` | external-content virtual table + sync triggers, `MATCH` / `bm25()` / `snippet()` | yes |
85
85
  | mysql | `fulltext` | `FULLTEXT` index, `MATCH … AGAINST` in natural language mode | no, typed `null` |
86
86
 
@@ -104,9 +104,56 @@ const hits = await db.post.search({ query: "piz", prefix: true, take: 8 });
104
104
  The query is split into unicode letter/number tokens (punctuation separates and never matches) and every token matches as a prefix. Per engine: tsvector builds `to_tsquery('lang', 'piz:*')` (still one bound parameter — `websearch_to_tsquery` has no prefix syntax), fts5 uses `"piz"*` in the MATCH string, mysql switches to `+piz*` in BOOLEAN MODE. A query with no tokens resolves to `[]` without touching the database. Three caveats:
105
105
 
106
106
  - tsvector prefixes run against **stemmed** lexemes, so a stemming `language` ("english", "norwegian") can miss mid-stem prefixes. Declare `language: "simple"` on models meant for type-ahead.
107
- - `engine: "bm25"` refuses `prefix` (`VIBE_UNSUPPORTED_CAPABILITY`) — ParadeDB prefix behavior is decided by the index tokenizer, which this extension does not configure.
107
+ - On `engine: "bm25"` the index decides: declare `tokenizer: "edge_ngram"` (below) and every token of the raw query matches as a prefix through `&&&`; without that index, `prefix` needs `fuzzy` (`pdb.fuzzy(n, t)`) or is refused with `VIBE_UNSUPPORTED_CAPABILITY`.
108
108
  - mysql InnoDB ignores tokens shorter than `innodb_ft_min_token_size` (default 3), so one- and two-letter prefixes match nothing there.
109
109
 
110
+ ### ParadeDB bm25 index options
111
+
112
+ The bm25 engine is the one engine whose index configuration decides matching behavior, so `@@search` carries it (every key below is bm25-only and refused on other engines). The address-picker shape — type-ahead over Norwegian street names typed without `ø`, scoped to a municipality:
113
+
114
+ ```prisma
115
+ model Address {
116
+ id Int @id @default(autoincrement())
117
+ street String
118
+ city String
119
+ municipalityNumber String
120
+ active Boolean @default(true)
121
+
122
+ @@search([street, city], weights: [2, 1],
123
+ tokenizer: "edge_ngram", minGram: 1, maxGram: 12, asciiFolding: true,
124
+ filterFields: [municipalityNumber, active])
125
+ }
126
+ ```
127
+
128
+ ```sql
129
+ CREATE INDEX IF NOT EXISTS "Address_search_idx" ON "Address" USING bm25 ("id",
130
+ ("street"::pdb.edge_ngram(1, 12, 'ascii_folding=true')), ("city"::pdb.edge_ngram(1, 12, 'ascii_folding=true')),
131
+ ("municipalityNumber"::pdb.literal), "active")
132
+ WITH (key_field='id', search_tokenizer='unicode_words(ascii_folding=true)');
133
+ ```
134
+
135
+ - `tokenizer`: `unicode_words` (ParadeDB's default), `simple`, `whitespace`, `ngram`, `edge_ngram`, `icu`, `literal_normalized`, `source_code`, `chinese_compatible`, `jieba`. One tokenizer per model, applied to every searched field. `ngram` / `edge_ngram` require integer `minGram` and `maxGram`; words shorter than `minGram` are never indexed (`minGram: 1` if house numbers must match).
136
+ - Token filters, appended to the tokenizer: `asciiFolding: true` (`ø` → `o`, `é` → `e`), `stemmer: "norwegian"` (a Snowball language), `lowercase: false`, `stopwordsLanguage: "english"` (one language). Filters alone imply `unicode_words`.
137
+ - For `edge_ngram` the extension also sets `search_tokenizer = unicode_words(<same filters>)`, so a query is tokenized into whole words while the index holds prefixes — without it `"sho"` would become `s`, `sh`, `sho` and match every s-word.
138
+ - `filterFields`: non-searched scalar columns added to the index so `where` filters on them are pushed into the ParadeDB scan instead of filtering the heap afterwards. `String` columns are indexed with `pdb.literal` (exact equality and `in`), `Boolean`/`Int`/`BigInt`/`Float`/`Decimal`/`DateTime` and `@db.Uuid` columns as-is; enums, JSON and list columns are refused. ParadeDB allows one index per table, so this is the index any hand-written `$querySql` over the table uses too.
139
+ - Column storage: searched fields must be `text` or `varchar` (`String`, `@db.Text`, `@db.VarChar(n)`); `filterFields` additionally accept `@db.Uuid`. `@db.Char(n)` (ParadeDB 0.25: "Type 1042 is not yet supported"), `@db.Inet`, `@db.Xml`, `@db.Citext`, `@db.Bit` and `Unsupported(…)` columns are refused with `VIBE_UNSUPPORTED_CAPABILITY` by `vibeorm generate` and by migrate alike.
140
+ - Changing any of these options (or the searched/filter field lists) after the index exists **rebuilds the index**: `migrate generate` lets the shadow server compare the replayed index with the declared one and emits `DROP INDEX` + `CREATE INDEX` (the down migration restores the previous definition); `db push` does the same when the migration history carries a desired-state snapshot. A bm25 rebuild re-tokenizes the whole table — budget for it in the migration window. A `db push` on a project without migration snapshots checks the index for presence by name only — pass `db push --rebuild-artifacts` (PostgreSQL) to drop and re-create every extension object from the current declarations, or drop the index by hand. `weights` are query-time boosts and never touch the index.
141
+
142
+ Query-time arguments on bm25:
143
+
144
+ ```ts
145
+ const hits = await db.address.search({
146
+ query: "stro",
147
+ prefix: true, // edge_ngram index: every token is a prefix
148
+ fuzzy: 1, // and may carry one typo (pdb.fuzzy(1, t)); ParadeDB caps it at 2
149
+ where: { municipalityNumber: "3005", active: true },
150
+ minScore: 0.5, // paradedb.score(id) >= 0.5 inside the index scan, before LIMIT
151
+ take: 8,
152
+ });
153
+ ```
154
+
155
+ `fuzzy` (1 or 2) is the Levenshtein edit distance per token; it switches the match from the `@@@` query parser (OR between words) to `&&&` (every token must match). It exists only on bm25 — on the other engines the typed `search` signature does not offer it and the runtime refuses it; use the trgm extension's `similarTo` there. `@@@` without `prefix`/`fuzzy` keeps ParadeDB's query-parser syntax (quoted phrases, `AND`/`OR`), exactly as before.
156
+
110
157
  ### Filtering hits: `where` composes with the core filter language
111
158
 
112
159
  `search({ where })` compiles through the same filter compiler as every core query — scalar
@@ -142,7 +189,7 @@ const hits = await db.menuItem.search({
142
189
  });
143
190
  ```
144
191
 
145
- For typo tolerance ("piza") declare `@@trgm([name])` too and fall back to
192
+ For typo tolerance ("piza") on bm25 pass `fuzzy: 1`; on the other postgres engines declare `@@trgm([name])` too and fall back to
146
193
  `findMany({ where: { name: { similarTo: query } } })` when a prefix search returns nothing
147
194
  (postgres/pglite only).
148
195
 
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // @bun
2
2
  // packages/extensions/src/search/index.ts
3
- import { VibeError as VibeError2, defineExtension, unsupportedCapability } from "@vibeorm/schema";
3
+ import { VibeError as VibeError2, assertIdentifierFits, defineExtension, fitDefaultName, unsupportedCapability } from "@vibeorm/schema";
4
4
 
5
5
  // packages/extensions/src/shared.ts
6
6
  import { VibeError } from "@vibeorm/schema";
@@ -85,10 +85,198 @@ async function pgExtensionCheck(params) {
85
85
 
86
86
  // packages/extensions/src/search/index.ts
87
87
  import { ParamCollector, getSqlDialect, renderExpr } from "@vibeorm/sql";
88
- var VERSION = "2.0.0-alpha.1";
88
+ var VERSION = "2.0.0-alpha.2";
89
89
  var SCORE_ALIAS = "_vibe_score";
90
90
  var HIGHLIGHT_ALIAS = "_vibe_highlight";
91
91
  var WEIGHT_LETTERS = ["A", "B", "C", "D"];
92
+ var BM25_TOKENIZERS = Object.freeze([
93
+ "unicode_words",
94
+ "simple",
95
+ "whitespace",
96
+ "ngram",
97
+ "edge_ngram",
98
+ "icu",
99
+ "literal_normalized",
100
+ "source_code",
101
+ "chinese_compatible",
102
+ "jieba"
103
+ ]);
104
+ var STEMMER_LANGUAGES = Object.freeze([
105
+ "arabic",
106
+ "czech",
107
+ "danish",
108
+ "dutch",
109
+ "english",
110
+ "finnish",
111
+ "french",
112
+ "german",
113
+ "greek",
114
+ "hungarian",
115
+ "italian",
116
+ "norwegian",
117
+ "polish",
118
+ "portuguese",
119
+ "romanian",
120
+ "russian",
121
+ "spanish",
122
+ "swedish",
123
+ "tamil",
124
+ "turkish"
125
+ ]);
126
+ var STOPWORD_LANGUAGES = Object.freeze([
127
+ "czech",
128
+ "danish",
129
+ "dutch",
130
+ "english",
131
+ "finnish",
132
+ "french",
133
+ "german",
134
+ "hungarian",
135
+ "italian",
136
+ "norwegian",
137
+ "polish",
138
+ "portuguese",
139
+ "russian",
140
+ "spanish",
141
+ "swedish"
142
+ ]);
143
+ var TOKENIZER_KEYS = ["tokenizer", "minGram", "maxGram", "stemmer", "asciiFolding", "lowercase", "stopwordsLanguage"];
144
+ function isPositiveInteger(value) {
145
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
146
+ }
147
+ function readBm25Tokenizer(declaration) {
148
+ if (TOKENIZER_KEYS.every((key) => declaration[key] === undefined))
149
+ return { tokenizer: undefined };
150
+ const name = declaration["tokenizer"] ?? "unicode_words";
151
+ if (typeof name !== "string" || !BM25_TOKENIZERS.includes(name)) {
152
+ return { error: `tokenizer must be one of ${BM25_TOKENIZERS.join(", ")}` };
153
+ }
154
+ const grams = name === "ngram" || name === "edge_ngram";
155
+ const minGram = declaration["minGram"];
156
+ const maxGram = declaration["maxGram"];
157
+ if (grams) {
158
+ if (!isPositiveInteger(minGram) || !isPositiveInteger(maxGram) || minGram > maxGram) {
159
+ return { error: `${name} needs integer minGram and maxGram with 1 <= minGram <= maxGram` };
160
+ }
161
+ } else if (minGram !== undefined || maxGram !== undefined) {
162
+ return { error: "minGram/maxGram apply only to the ngram and edge_ngram tokenizers" };
163
+ }
164
+ const filters = [];
165
+ const lowercase = declaration["lowercase"];
166
+ if (lowercase !== undefined && typeof lowercase !== "boolean")
167
+ return { error: "lowercase must be a boolean" };
168
+ if (lowercase === false)
169
+ filters.push("lowercase=false");
170
+ const folding = declaration["asciiFolding"];
171
+ if (folding !== undefined && typeof folding !== "boolean")
172
+ return { error: "asciiFolding must be a boolean" };
173
+ if (folding === true)
174
+ filters.push("ascii_folding=true");
175
+ const stemmer = declaration["stemmer"];
176
+ if (stemmer !== undefined) {
177
+ if (typeof stemmer !== "string" || !STEMMER_LANGUAGES.includes(stemmer.toLowerCase())) {
178
+ return { error: `stemmer must be one of ${STEMMER_LANGUAGES.join(", ")}` };
179
+ }
180
+ filters.push(`stemmer=${stemmer.toLowerCase()}`);
181
+ }
182
+ const stopwords = declaration["stopwordsLanguage"];
183
+ if (stopwords !== undefined) {
184
+ if (typeof stopwords !== "string" || !STOPWORD_LANGUAGES.includes(stopwords.toLowerCase())) {
185
+ return { error: `stopwordsLanguage must be one of ${STOPWORD_LANGUAGES.join(", ")}` };
186
+ }
187
+ filters.push(`stopwords_language=${stopwords.toLowerCase()}`);
188
+ }
189
+ return {
190
+ tokenizer: {
191
+ name,
192
+ ...grams ? { minGram, maxGram } : {},
193
+ filters
194
+ }
195
+ };
196
+ }
197
+ function bm25TokenizerArguments(params) {
198
+ const { dialect, tokenizer } = params;
199
+ const args = [
200
+ ...tokenizer.minGram === undefined ? [] : [String(tokenizer.minGram), String(tokenizer.maxGram)],
201
+ ...tokenizer.filters.map((filter) => dialect.quoteStringLiteral({ value: filter }))
202
+ ];
203
+ return args.length === 0 ? "" : `(${args.join(", ")})`;
204
+ }
205
+ function bm25TokenizedColumn(params) {
206
+ const { dialect, column, tokenizer } = params;
207
+ const quoted = dialect.quoteIdent({ name: column });
208
+ if (tokenizer === undefined)
209
+ return quoted;
210
+ return `(${quoted}::pdb.${tokenizer.name}${bm25TokenizerArguments({ dialect, tokenizer })})`;
211
+ }
212
+ function bm25SearchTokenizer(params) {
213
+ const { tokenizer } = params;
214
+ if (tokenizer === undefined || tokenizer.name !== "edge_ngram")
215
+ return;
216
+ return tokenizer.filters.length === 0 ? "unicode_words" : `unicode_words(${tokenizer.filters.join(",")})`;
217
+ }
218
+ var BM25_FILTER_SCALARS = Object.freeze(["Boolean", "Int", "BigInt", "Float", "Decimal", "DateTime"]);
219
+ var BM25_BOOST_LIMIT = 2048;
220
+ function bm25StringStorage(field) {
221
+ if (field.isUnsupported === true || field.isList || field.type !== "String")
222
+ return null;
223
+ const native = (field.nativeType ?? "").toLowerCase();
224
+ if (native === "" || native === "text" || native.startsWith("varchar"))
225
+ return "text";
226
+ if (native === "uuid")
227
+ return "uuid";
228
+ return null;
229
+ }
230
+ function describeField(field) {
231
+ const scalar = typeof field.type === "string" ? field.type : `enum ${field.type.enum}`;
232
+ return `${scalar}${field.isList ? "[]" : ""}${field.nativeType === undefined ? "" : ` @db.${field.nativeType}`}`;
233
+ }
234
+ function bm25IndexColumns(params) {
235
+ const { model, decl } = params;
236
+ const pkField = primaryKeyFieldOf({ model });
237
+ if (pkField === null) {
238
+ throw new VibeError2({
239
+ code: "VIBE_SCHEMA_INVALID",
240
+ message: `@@search with engine "bm25" needs a single-column primary key on "${model.name}" (key_field)`,
241
+ meta: { model: model.name }
242
+ });
243
+ }
244
+ const searched = decl.fields.map((name) => {
245
+ const field = model.fields.find((candidate) => candidate.name === name);
246
+ if (field === undefined)
247
+ return columnNameOf({ model, field: name });
248
+ if (bm25StringStorage(field) !== "text") {
249
+ throw unsupportedCapability({
250
+ dialect: "postgres",
251
+ feature: `@@search field "${name}" (${describeField(field)}) on the bm25 index`,
252
+ hint: "ParadeDB tokenizes text and varchar columns; search another column or store this one as text"
253
+ });
254
+ }
255
+ return field.dbName ?? field.name;
256
+ });
257
+ const filters = (decl.filterFields ?? []).map((name) => {
258
+ const field = model.fields.find((candidate) => candidate.name === name);
259
+ if (field === undefined || name === pkField || decl.fields.includes(name)) {
260
+ throw new VibeError2({
261
+ code: "VIBE_SCHEMA_INVALID",
262
+ message: `@@search filterFields on "${model.name}": "${name}" must be a scalar field that is neither the primary key nor a searched field`,
263
+ meta: { model: model.name, field: name }
264
+ });
265
+ }
266
+ const column = field.dbName ?? field.name;
267
+ const storage = bm25StringStorage(field);
268
+ if (storage !== null)
269
+ return { column, literal: storage === "text" };
270
+ if (!field.isList && typeof field.type === "string" && BM25_FILTER_SCALARS.includes(field.type))
271
+ return { column, literal: false };
272
+ throw unsupportedCapability({
273
+ dialect: "postgres",
274
+ feature: `@@search filterFields entry "${name}" (${describeField(field)}) on the bm25 index`,
275
+ hint: "ParadeDB pushes filters into the index for text/varchar (exact match / IN), uuid, Boolean, Int, BigInt, Float, Decimal and DateTime columns \u2014 filter other columns outside the index"
276
+ });
277
+ });
278
+ return { pkColumn: columnNameOf({ model, field: pkField }), searched, filters };
279
+ }
92
280
  function resolveSearchEngine(params) {
93
281
  const { dialect, provider, requested } = params;
94
282
  const native = dialect === "postgres" ? "tsvector" : dialect === "sqlite" ? "fts5" : "fulltext";
@@ -123,6 +311,10 @@ function readDeclaration(params) {
123
311
  if (params.declaration["weights"] !== undefined && (!Array.isArray(params.declaration["weights"]) || params.declaration["weights"].length !== fields.length || params.declaration["weights"].some((weight) => typeof weight !== "number" || !Number.isFinite(weight)))) {
124
312
  throw new VibeError2({ code: "VIBE_SCHEMA_INVALID", message: "weights must be a finite number list matching the field list", meta: { model: params.model.name } });
125
313
  }
314
+ const tokenizer = readBm25Tokenizer(params.declaration);
315
+ if ("error" in tokenizer) {
316
+ throw new VibeError2({ code: "VIBE_SCHEMA_INVALID", message: `@@search on "${params.model.name}": ${tokenizer.error}`, meta: { model: params.model.name } });
317
+ }
126
318
  return {
127
319
  fields,
128
320
  weights: declNumberArray(params.declaration, "weights"),
@@ -130,7 +322,9 @@ function readDeclaration(params) {
130
322
  language: declString(params.declaration, "language"),
131
323
  columnName: declString(params.declaration, "columnName"),
132
324
  indexName: declString(params.declaration, "indexName"),
133
- rank: declRank(params.declaration)
325
+ rank: declRank(params.declaration),
326
+ tokenizer: tokenizer.tokenizer,
327
+ filterFields: declStringArray(params.declaration, "filterFields")
134
328
  };
135
329
  }
136
330
  function weightLetters(params) {
@@ -156,18 +350,30 @@ function resolveArtifactNames(params) {
156
350
  hint: "only the tsvector engine materializes a generated column \u2014 drop columnName or target tsvector"
157
351
  });
158
352
  }
353
+ const remedy = "Shorten it, or set a shorter `name:` on the @@search declaration.";
354
+ if (decl.columnName !== undefined)
355
+ assertIdentifierFits({ name: decl.columnName, dialect, what: "@@search columnName", remedy });
356
+ if (decl.indexName !== undefined)
357
+ assertIdentifierFits({ name: decl.indexName, dialect, what: "@@search indexName", remedy });
159
358
  return {
160
- tsvColumn: decl.columnName ?? `${decl.base}_tsv`,
161
- indexName: decl.indexName ?? `${decl.base}_idx`
359
+ tsvColumn: decl.columnName ?? fitDefaultName({ prefix: decl.base, suffix: "_tsv", dialect }),
360
+ indexName: decl.indexName ?? fitDefaultName({ prefix: decl.base, suffix: "_idx", dialect })
162
361
  };
163
362
  }
164
363
  function resolveRank(params) {
165
364
  const { decl, options, engine, dialect } = params;
166
- if (decl.weights !== undefined && engine !== "tsvector") {
365
+ if (decl.weights !== undefined && engine !== "tsvector" && engine !== "bm25") {
167
366
  throw unsupportedCapability({
168
367
  dialect,
169
368
  feature: `@@search weights on the ${engine} engine`,
170
- hint: "weights are supported only by tsvector; omit weights for this engine"
369
+ hint: "weights are supported by tsvector (setweight classes) and bm25 (pdb.boost factors); omit weights for this engine"
370
+ });
371
+ }
372
+ if (decl.weights !== undefined && engine === "bm25" && decl.weights.some((weight) => Math.abs(weight) > BM25_BOOST_LIMIT)) {
373
+ throw new VibeError2({
374
+ code: "VIBE_SCHEMA_INVALID",
375
+ message: `@@search weights on the bm25 engine are pdb.boost factors and must stay within \xB1${BM25_BOOST_LIMIT}`,
376
+ meta: { weights: decl.weights }
171
377
  });
172
378
  }
173
379
  if (decl.rank !== undefined && engine !== "tsvector") {
@@ -182,6 +388,25 @@ function resolveRank(params) {
182
388
  const fallback = options["rank"];
183
389
  return decl.rank ?? (fallback === "ts_rank" || fallback === "ts_rank_cd" ? fallback : undefined);
184
390
  }
391
+ function assertBm25Declarations(params) {
392
+ const { decl, engine, dialect } = params;
393
+ if (engine === "bm25")
394
+ return;
395
+ if (decl.tokenizer !== undefined) {
396
+ throw unsupportedCapability({
397
+ dialect,
398
+ feature: `@@search tokenizer options on the ${engine} engine`,
399
+ hint: 'tokenizer, minGram, maxGram, stemmer, asciiFolding, lowercase and stopwordsLanguage configure a ParadeDB bm25 index \u2014 drop them, or opt in with search({ engine: "bm25" })'
400
+ });
401
+ }
402
+ if (decl.filterFields !== undefined) {
403
+ throw unsupportedCapability({
404
+ dialect,
405
+ feature: `@@search filterFields on the ${engine} engine`,
406
+ hint: 'filterFields add columns to a ParadeDB bm25 index for filter pushdown \u2014 drop them, or opt in with search({ engine: "bm25" })'
407
+ });
408
+ }
409
+ }
185
410
  function tsvectorExpression(params) {
186
411
  const { dialect, model, decl, language } = params;
187
412
  const quote = (name) => dialect.quoteIdent({ name });
@@ -202,7 +427,7 @@ function searchArtifacts(ctx) {
202
427
  artifacts.push({
203
428
  id: "search:extension:pg_search",
204
429
  owns: { kind: "postgresExtension", name: "pg_search" },
205
- create: ["CREATE EXTENSION IF NOT EXISTS pg_search;"],
430
+ create: ["CREATE EXTENSION IF NOT EXISTS pg_search CASCADE;"],
206
431
  drop: ["DROP EXTENSION IF EXISTS pg_search;"]
207
432
  });
208
433
  }
@@ -216,6 +441,7 @@ function searchArtifacts(ctx) {
216
441
  const table = tableNameOf({ model });
217
442
  const columns = decl.fields.map((field) => columnNameOf({ model, field }));
218
443
  resolveRank({ decl, options: ctx.options, engine, dialect: ctx.dialect });
444
+ assertBm25Declarations({ decl, engine, dialect: ctx.dialect });
219
445
  if (engine === "tsvector") {
220
446
  const language = decl.language ?? ctx.options["language"] ?? "english";
221
447
  const { tsvColumn, indexName } = resolveArtifactNames({ decl, engine, dialect: ctx.dialect });
@@ -238,21 +464,22 @@ function searchArtifacts(ctx) {
238
464
  continue;
239
465
  }
240
466
  if (engine === "bm25") {
241
- const pkField = primaryKeyFieldOf({ model });
242
- if (pkField === null) {
243
- throw new VibeError2({
244
- code: "VIBE_SCHEMA_INVALID",
245
- message: `@@search with engine "bm25" needs a single-column primary key on "${modelName}" (key_field)`,
246
- meta: { model: modelName }
247
- });
248
- }
249
- const pkColumn = columnNameOf({ model, field: pkField });
467
+ const { pkColumn, searched, filters } = bm25IndexColumns({ model, decl });
250
468
  const bm25Names = resolveArtifactNames({ decl, engine, dialect: ctx.dialect });
469
+ const indexed = [
470
+ quote(pkColumn),
471
+ ...searched.map((column) => bm25TokenizedColumn({ dialect, column, tokenizer: decl.tokenizer })),
472
+ ...filters.map((filter) => filter.literal ? `(${quote(filter.column)}::pdb.literal)` : quote(filter.column))
473
+ ];
474
+ const withOptions = [`key_field=${dialect.quoteStringLiteral({ value: pkColumn })}`];
475
+ const searchTokenizer = bm25SearchTokenizer({ tokenizer: decl.tokenizer });
476
+ if (searchTokenizer !== undefined)
477
+ withOptions.push(`search_tokenizer=${dialect.quoteStringLiteral({ value: searchTokenizer })}`);
251
478
  artifacts.push({
252
479
  id: `search:${modelName}:index`,
253
480
  owns: { kind: "index", table, name: bm25Names.indexName },
254
481
  create: [
255
- `CREATE INDEX IF NOT EXISTS ${quote(bm25Names.indexName)} ON ${quote(table)} USING bm25 (${[pkColumn, ...columns].map((column) => quote(column)).join(", ")}) WITH (key_field='${pkColumn}');`
482
+ `CREATE INDEX IF NOT EXISTS ${quote(bm25Names.indexName)} ON ${quote(table)} USING bm25 (${indexed.join(", ")}) WITH (${withOptions.join(", ")});`
256
483
  ],
257
484
  drop: [`DROP INDEX IF EXISTS ${quote(bm25Names.indexName)};`]
258
485
  });
@@ -282,7 +509,7 @@ function searchArtifacts(ctx) {
282
509
  auxiliaryPrefixes: [`${fts}_`]
283
510
  },
284
511
  create: [
285
- `CREATE VIRTUAL TABLE IF NOT EXISTS ${quote(fts)} USING fts5(${columnList}, content='${table}');`
512
+ `CREATE VIRTUAL TABLE IF NOT EXISTS ${quote(fts)} USING fts5(${columnList}, content=${dialect.quoteStringLiteral({ value: table })});`
286
513
  ],
287
514
  drop: [`DROP TABLE IF EXISTS ${quote(fts)};`]
288
515
  }, {
@@ -367,11 +594,26 @@ function createSearchDelegate(config) {
367
594
  if (args.minScore !== undefined)
368
595
  finiteNumber({ value: args.minScore, name: "minScore" });
369
596
  const prefix = args.prefix === true;
370
- if (prefix && config.engine === "bm25") {
597
+ const fuzzy = args.fuzzy;
598
+ if (fuzzy !== undefined && fuzzy !== 1 && fuzzy !== 2) {
599
+ throw new VibeError2({
600
+ code: "VIBE_VALIDATION",
601
+ message: "fuzzy must be 1 or 2 (ParadeDB caps the edit distance at 2)",
602
+ meta: { argument: "fuzzy" }
603
+ });
604
+ }
605
+ if (fuzzy !== undefined && config.engine !== "bm25") {
606
+ throw unsupportedCapability({
607
+ dialect: dialect.name,
608
+ feature: `search fuzzy matching on the "${config.engine}" engine`,
609
+ hint: "only the bm25 engine (ParadeDB pdb.fuzzy) matches within an edit distance \u2014 on postgres/pglite declare @@trgm and filter with similarTo"
610
+ });
611
+ }
612
+ if (prefix && config.engine === "bm25" && config.prefixIndex !== true && fuzzy === undefined) {
371
613
  throw unsupportedCapability({
372
614
  dialect: "postgres",
373
- feature: 'search prefix mode on the "bm25" engine',
374
- hint: "ParadeDB prefix behavior is decided by the index tokenizer (ngram/edge-ngram) \u2014 configure the index instead, or use the tsvector engine"
615
+ feature: 'search prefix mode on the "bm25" engine without an edge_ngram index',
616
+ hint: 'declare @@search(\u2026, tokenizer: "edge_ngram", minGram: 1, maxGram: 12) so every token is indexed as a prefix, or pass fuzzy: 1 | 2 for fuzzy prefix matching (pdb.fuzzy(n, t))'
375
617
  });
376
618
  }
377
619
  let matchText = args.query;
@@ -379,7 +621,7 @@ function createSearchDelegate(config) {
379
621
  const tokens = args.query.match(/[\p{L}\p{N}]+/gu) ?? [];
380
622
  if (tokens.length === 0)
381
623
  return [];
382
- matchText = config.engine === "tsvector" ? tokens.map((token) => `${token}:*`).join(" & ") : config.engine === "fts5" ? tokens.map((token) => `"${token}"*`).join(" ") : tokens.map((token) => `+${token}*`).join(" ");
624
+ matchText = config.engine === "tsvector" ? tokens.map((token) => `${token}:*`).join(" & ") : config.engine === "fts5" ? tokens.map((token) => `"${token}"*`).join(" ") : config.engine === "bm25" ? args.query : tokens.map((token) => `+${token}*`).join(" ");
383
625
  }
384
626
  const hlColumn = highlightColumn({ config, args });
385
627
  const idOnly = args.select === "id";
@@ -450,31 +692,35 @@ function createSearchDelegate(config) {
450
692
  text += ` ORDER BY ${quote(SCORE_ALIAS)} DESC`;
451
693
  } else {
452
694
  const pk = `${t}.${quote(config.pkColumn ?? "")}`;
695
+ const operator = prefix || fuzzy !== undefined ? "&&&" : "@@@";
696
+ const fuzzyCast = fuzzy === undefined ? "" : `::pdb.fuzzy(${fuzzy}${prefix ? ", t" : ""})`;
453
697
  text = `SELECT ${projection}, paradedb.score(${pk}) AS ${quote(SCORE_ALIAS)}`;
454
698
  if (hlColumn !== null) {
455
699
  text += `, paradedb.snippet(${t}.${quote(hlColumn)}) AS ${quote(HIGHLIGHT_ALIAS)}`;
456
700
  }
457
- text += ` FROM ${quote(config.table)} AS ${t} WHERE (${config.fields.map((field) => `${t}.${quote(field.column)} @@@ ${collector.add(args.query)}`).join(" OR ")})`;
701
+ text += ` FROM ${quote(config.table)} AS ${t} WHERE (${config.fields.map((field, index) => {
702
+ const boost = config.boosts?.[index];
703
+ const boostCast = boost === undefined || boost === 1 ? "" : `::pdb.boost(${boost})`;
704
+ return `${t}.${quote(field.column)} ${operator} ${collector.add(matchText)}${fuzzyCast}${boostCast}`;
705
+ }).join(" OR ")})`;
458
706
  const where = userWhere("t");
459
707
  if (where !== null)
460
708
  text += ` AND (${where})`;
709
+ if (args.minScore !== undefined) {
710
+ text += ` AND paradedb.score(${pk}) >= ${collector.add(args.minScore)}`;
711
+ }
461
712
  text += ` ORDER BY ${quote(SCORE_ALIAS)} DESC`;
462
713
  }
463
714
  text += ` LIMIT ${take}`;
464
715
  if (skip !== null)
465
716
  text += ` OFFSET ${skip}`;
466
717
  const rows = await ctx.executeRows({ text, values: collector.values, method: "search" });
467
- let hits = shapeHits({
718
+ return shapeHits({
468
719
  rows,
469
720
  strip,
470
721
  highlightsRequested: args.highlight !== undefined,
471
722
  engineHasHighlights
472
723
  });
473
- if (config.engine === "bm25" && args.minScore !== undefined) {
474
- const min = args.minScore;
475
- hits = hits.filter((hit) => hit.score >= min);
476
- }
477
- return hits;
478
724
  };
479
725
  };
480
726
  }
@@ -521,6 +767,19 @@ function search(options = {}) {
521
767
  if (rank !== undefined && rank !== "ts_rank" && rank !== "ts_rank_cd") {
522
768
  return { error: `rank must be "ts_rank" or "ts_rank_cd"` };
523
769
  }
770
+ const tokenizer = readBm25Tokenizer(args.named);
771
+ if ("error" in tokenizer)
772
+ return { error: tokenizer.error };
773
+ const filterFields = args.named["filterFields"];
774
+ if (filterFields !== undefined) {
775
+ if (!Array.isArray(filterFields) || filterFields.length === 0 || filterFields.some((name) => typeof name !== "string")) {
776
+ return { error: "filterFields must be a non-empty field list, e.g. filterFields: [municipalityNumber, active]" };
777
+ }
778
+ const invalid = filterFields.find((name) => !fieldNames.includes(String(name)) || fields.includes(String(name)));
779
+ if (invalid !== undefined) {
780
+ return { error: `filterFields entry "${String(invalid)}" must be a field on the model that is not also searched` };
781
+ }
782
+ }
524
783
  return { declaration: { fields, ...args.named } };
525
784
  }
526
785
  }
@@ -545,6 +804,9 @@ function search(options = {}) {
545
804
  }
546
805
  const pkField = primaryKeyFieldOf({ model });
547
806
  const rank = resolveRank({ decl, options: extOptions, engine, dialect });
807
+ assertBm25Declarations({ decl, engine, dialect });
808
+ if (engine === "bm25")
809
+ bm25IndexColumns({ model, decl });
548
810
  const names = resolveArtifactNames({ decl, engine, dialect });
549
811
  const config = {
550
812
  engine,
@@ -555,7 +817,9 @@ function search(options = {}) {
555
817
  })),
556
818
  ...engine === "tsvector" ? { tsvColumn: names.tsvColumn, language, ...rank === undefined ? {} : { rank } } : {},
557
819
  ...engine === "fts5" ? { ftsTable: `${decl.base}_fts` } : {},
558
- ...pkField !== null ? { pkColumn: columnNameOf({ model, field: pkField }) } : {}
820
+ ...pkField !== null ? { pkColumn: columnNameOf({ model, field: pkField }) } : {},
821
+ ...engine === "bm25" && decl.weights !== undefined ? { boosts: decl.weights } : {},
822
+ ...engine === "bm25" && decl.tokenizer?.name === "edge_ngram" ? { prefixIndex: true } : {}
559
823
  };
560
824
  const fieldUnion = decl.fields.map((field) => JSON.stringify(field)).join(" | ");
561
825
  const argLines = [
@@ -565,7 +829,8 @@ function search(options = {}) {
565
829
  ` readonly skip?: number;`,
566
830
  ` readonly minScore?: number;`,
567
831
  ` readonly highlight?: { readonly field: ${fieldUnion} };`,
568
- ` readonly prefix?: boolean;`
832
+ ` readonly prefix?: boolean;`,
833
+ ...engine === "bm25" ? [` readonly fuzzy?: 1 | 2;`] : []
569
834
  ];
570
835
  const dts = pkField === null ? [
571
836
  `readonly search: (args: {`,
@@ -628,7 +893,7 @@ function search(options = {}) {
628
893
  });
629
894
  }
630
895
  // packages/extensions/src/vector/index.ts
631
- import { VibeError as VibeError3, defineExtension as defineExtension2, unsupportedCapability as unsupportedCapability2 } from "@vibeorm/schema";
896
+ import { VibeError as VibeError3, assertIdentifierFits as assertIdentifierFits2, defineExtension as defineExtension2, fitDefaultName as fitDefaultName2, unsupportedCapability as unsupportedCapability2 } from "@vibeorm/schema";
632
897
  import { ParamCollector as ParamCollector2, getSqlDialect as getSqlDialect2, renderExpr as renderExpr2 } from "@vibeorm/sql";
633
898
  var VERSION2 = "2.0.0-alpha.0";
634
899
  var METRIC_OPERATOR = {
@@ -702,6 +967,13 @@ function vectorArtifacts(ctx) {
702
967
  const columns = vectorColumnsOf({ declaration });
703
968
  const indexFields = declStringArray(declaration, "fields") ?? [];
704
969
  const kindRaw = declString(declaration, "index") ?? "hnsw";
970
+ if (!INDEX_KINDS.includes(kindRaw)) {
971
+ throw new VibeError3({
972
+ code: "VIBE_SCHEMA_INVALID",
973
+ message: `@@vector on "${modelName}" declares index kind "${kindRaw}" \u2014 expected one of ${INDEX_KINDS.join(", ")}`,
974
+ meta: { model: modelName, index: kindRaw }
975
+ });
976
+ }
705
977
  const metricRaw = declString(declaration, "metric") ?? "cosine";
706
978
  const metric = METRICS.find((candidate) => candidate === metricRaw) ?? "cosine";
707
979
  for (const field of indexFields) {
@@ -714,7 +986,9 @@ function vectorArtifacts(ctx) {
714
986
  }
715
987
  const column = columnNameOf({ model, field });
716
988
  const nameOverride = indexFields.length === 1 ? declString(declaration, "indexName") : undefined;
717
- const indexName = nameOverride ?? `${table}_${column}_${kindRaw}_idx`;
989
+ if (nameOverride !== undefined)
990
+ assertIdentifierFits2({ name: nameOverride, dialect: ctx.dialect, what: "@@vector indexName", remedy: "Shorten it." });
991
+ const indexName = nameOverride ?? fitDefaultName2({ prefix: `${table}_${column}_${kindRaw}`, suffix: "_idx", dialect: ctx.dialect });
718
992
  const withOptions = [];
719
993
  const m = declaration["m"];
720
994
  const efConstruction = declaration["efConstruction"];
@@ -939,7 +1213,7 @@ function vector(options = {}) {
939
1213
  });
940
1214
  }
941
1215
  // packages/extensions/src/trgm/index.ts
942
- import { VibeError as VibeError4, defineExtension as defineExtension3, unsupportedCapability as unsupportedCapability3 } from "@vibeorm/schema";
1216
+ import { VibeError as VibeError4, defineExtension as defineExtension3, fitDefaultName as fitDefaultName3, unsupportedCapability as unsupportedCapability3 } from "@vibeorm/schema";
943
1217
  import { getSqlDialect as getSqlDialect3 } from "@vibeorm/sql";
944
1218
  var DEFAULT_THRESHOLD = 0.3;
945
1219
  var VERSION3 = "2.0.0-alpha.0";
@@ -971,7 +1245,7 @@ function trgmArtifacts(ctx) {
971
1245
  const table = tableNameOf({ model });
972
1246
  for (const field of declStringArray(declaration, "fields") ?? []) {
973
1247
  const column = columnNameOf({ model, field });
974
- const indexName = `${table}_${column}_trgm_idx`;
1248
+ const indexName = fitDefaultName3({ prefix: `${table}_${column}_trgm`, suffix: "_idx", dialect: ctx.dialect });
975
1249
  artifacts.push({
976
1250
  id: `trgm:${modelName}:${field}`,
977
1251
  owns: { kind: "index", table, name: indexName },
@@ -1126,4 +1400,4 @@ export {
1126
1400
  vector
1127
1401
  };
1128
1402
 
1129
- //# debugId=CB654FBC580AA6A164756E2164756E21
1403
+ //# debugId=8FC9F7443F3A892664756E2164756E21