@vibeorm/extensions 2.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,966 @@
1
+ // @bun
2
+ // packages/extensions/src/search/index.ts
3
+ import { VibeError as VibeError2, defineExtension, unsupportedCapability } from "@vibeorm/schema";
4
+ import { ParamCollector, getSqlDialect, renderExpr } from "@vibeorm/sql";
5
+
6
+ // packages/extensions/src/shared.ts
7
+ import { VibeError } from "@vibeorm/schema";
8
+ function tableNameOf(params) {
9
+ return params.model.dbName ?? params.model.name;
10
+ }
11
+ function columnNameOf(params) {
12
+ const found = params.model.fields.find((candidate) => candidate.name === params.field);
13
+ if (found === undefined) {
14
+ throw new VibeError({
15
+ code: "VIBE_INTERNAL",
16
+ message: `extension referenced unknown field "${params.field}" on model "${params.model.name}"`,
17
+ meta: { model: params.model.name, field: params.field }
18
+ });
19
+ }
20
+ return found.dbName ?? found.name;
21
+ }
22
+ function primaryKeyFieldOf(params) {
23
+ const { model } = params;
24
+ if (model.primaryKey !== undefined && model.primaryKey.fields.length === 1) {
25
+ return model.primaryKey.fields[0] ?? null;
26
+ }
27
+ const idField = model.fields.find((field) => field.isId);
28
+ return idField?.name ?? null;
29
+ }
30
+ function declString(decl, key) {
31
+ const value = decl[key];
32
+ return typeof value === "string" ? value : undefined;
33
+ }
34
+ function declStringArray(decl, key) {
35
+ const value = decl[key];
36
+ if (!Array.isArray(value))
37
+ return;
38
+ return value.every((entry) => typeof entry === "string") ? value : undefined;
39
+ }
40
+ function declNumberArray(decl, key) {
41
+ const value = decl[key];
42
+ if (!Array.isArray(value))
43
+ return;
44
+ return value.every((entry) => typeof entry === "number") ? value : undefined;
45
+ }
46
+ var SQL_LITERAL_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
47
+ function isSafeSqlName(params) {
48
+ return SQL_LITERAL_NAME.test(params.name);
49
+ }
50
+ async function pgExtensionCheck(params) {
51
+ const { execute, name } = params;
52
+ try {
53
+ const rows = await execute({
54
+ text: "SELECT name, default_version, installed_version FROM pg_available_extensions WHERE name = $1",
55
+ values: [name]
56
+ });
57
+ const row = rows[0];
58
+ if (row === undefined) {
59
+ return { name: `extension ${name}`, ok: false, note: "not available on this server" };
60
+ }
61
+ const installed = row["installed_version"];
62
+ return {
63
+ name: `extension ${name}`,
64
+ ok: true,
65
+ note: installed === null || installed === undefined ? "available, not installed" : `installed ${String(installed)}`
66
+ };
67
+ } catch (error) {
68
+ return {
69
+ name: `extension ${name}`,
70
+ ok: false,
71
+ note: `check failed: ${error instanceof Error ? error.message : String(error)}`
72
+ };
73
+ }
74
+ }
75
+
76
+ // packages/extensions/src/search/index.ts
77
+ var VERSION = "2.0.0-alpha.0";
78
+ var SCORE_ALIAS = "_vibe_score";
79
+ var HIGHLIGHT_ALIAS = "_vibe_highlight";
80
+ var WEIGHT_LETTERS = ["A", "B", "C", "D"];
81
+ function resolveSearchEngine(params) {
82
+ const { dialect, provider, requested } = params;
83
+ const native = dialect === "postgres" ? "tsvector" : dialect === "sqlite" ? "fts5" : "fulltext";
84
+ if (requested === "auto")
85
+ return native;
86
+ if (requested === native)
87
+ return native;
88
+ if (requested === "bm25" && dialect === "postgres") {
89
+ if (provider === "pglite") {
90
+ throw unsupportedCapability({
91
+ dialect,
92
+ feature: 'the search engine "bm25" (ParadeDB pg_search)',
93
+ hint: 'pglite does not bundle pg_search \u2014 use engine "auto"/"tsvector"'
94
+ });
95
+ }
96
+ return "bm25";
97
+ }
98
+ throw unsupportedCapability({
99
+ dialect,
100
+ feature: `the search engine "${requested}"`,
101
+ hint: `this dialect supports "${native}"${dialect === "postgres" ? ' and "bm25"' : ""}`
102
+ });
103
+ }
104
+ function readDeclaration(params) {
105
+ const fields = declStringArray(params.declaration, "fields");
106
+ if (fields === undefined || fields.length === 0)
107
+ return null;
108
+ return {
109
+ fields,
110
+ weights: declNumberArray(params.declaration, "weights"),
111
+ base: declString(params.declaration, "name") ?? `${tableNameOf({ model: params.model })}_search`,
112
+ language: declString(params.declaration, "language")
113
+ };
114
+ }
115
+ function weightLetters(params) {
116
+ const { decl } = params;
117
+ if (decl.weights === undefined || decl.weights.length !== decl.fields.length)
118
+ return null;
119
+ const ranked = [...new Set(decl.weights)].sort((a, b) => b - a);
120
+ return decl.weights.map((weight) => WEIGHT_LETTERS[Math.min(ranked.indexOf(weight), WEIGHT_LETTERS.length - 1)]);
121
+ }
122
+ function tsvectorExpression(params) {
123
+ const { dialect, model, decl, language } = params;
124
+ const quote = (name) => dialect.quoteIdent({ name });
125
+ const letters = weightLetters({ decl });
126
+ const parts = decl.fields.map((field, index) => {
127
+ const vector = `to_tsvector('${language}', coalesce(${quote(columnNameOf({ model, field }))}, ''))`;
128
+ return letters === null ? vector : `setweight(${vector}, '${letters[index]}')`;
129
+ });
130
+ return letters === null && parts.length > 1 ? `to_tsvector('${language}', ${decl.fields.map((field) => `coalesce(${quote(columnNameOf({ model, field }))}, '')`).join(" || ' ' || ")})` : parts.join(" || ");
131
+ }
132
+ function searchArtifacts(ctx) {
133
+ const requested = ctx.options["engine"] ?? "auto";
134
+ const engine = resolveSearchEngine({ dialect: ctx.dialect, provider: ctx.provider, requested });
135
+ const dialect = getSqlDialect({ dialect: ctx.dialect });
136
+ const quote = (name) => dialect.quoteIdent({ name });
137
+ const artifacts = [];
138
+ if (engine === "bm25") {
139
+ artifacts.push({
140
+ id: "search:extension:pg_search",
141
+ owns: { kind: "postgresExtension", name: "pg_search" },
142
+ create: ["CREATE EXTENSION IF NOT EXISTS pg_search;"],
143
+ drop: ["DROP EXTENSION IF EXISTS pg_search;"]
144
+ });
145
+ }
146
+ for (const [modelName, declaration] of Object.entries(ctx.declarations)) {
147
+ const model = ctx.schema.models.find((candidate) => candidate.name === modelName);
148
+ if (model === undefined)
149
+ continue;
150
+ const decl = readDeclaration({ model, declaration });
151
+ if (decl === null)
152
+ continue;
153
+ const table = tableNameOf({ model });
154
+ const columns = decl.fields.map((field) => columnNameOf({ model, field }));
155
+ if (engine === "tsvector") {
156
+ const language = decl.language ?? ctx.options["language"] ?? "english";
157
+ const tsvColumn = `${decl.base}_tsv`;
158
+ const expression = tsvectorExpression({ dialect, model, decl, language });
159
+ artifacts.push({
160
+ id: `search:${modelName}:column`,
161
+ owns: { kind: "column", table, column: tsvColumn },
162
+ create: [
163
+ `ALTER TABLE ${quote(table)} ADD COLUMN IF NOT EXISTS ${quote(tsvColumn)} tsvector GENERATED ALWAYS AS (${expression}) STORED;`
164
+ ],
165
+ drop: [`ALTER TABLE ${quote(table)} DROP COLUMN IF EXISTS ${quote(tsvColumn)};`]
166
+ }, {
167
+ id: `search:${modelName}:index`,
168
+ owns: { kind: "index", table, name: `${decl.base}_idx` },
169
+ create: [
170
+ `CREATE INDEX IF NOT EXISTS ${quote(`${decl.base}_idx`)} ON ${quote(table)} USING gin (${quote(tsvColumn)});`
171
+ ],
172
+ drop: [`DROP INDEX IF EXISTS ${quote(`${decl.base}_idx`)};`]
173
+ });
174
+ continue;
175
+ }
176
+ if (engine === "bm25") {
177
+ const pkField = primaryKeyFieldOf({ model });
178
+ if (pkField === null) {
179
+ throw new VibeError2({
180
+ code: "VIBE_SCHEMA_INVALID",
181
+ message: `@@search with engine "bm25" needs a single-column primary key on "${modelName}" (key_field)`,
182
+ meta: { model: modelName }
183
+ });
184
+ }
185
+ const pkColumn = columnNameOf({ model, field: pkField });
186
+ artifacts.push({
187
+ id: `search:${modelName}:index`,
188
+ owns: { kind: "index", table, name: `${decl.base}_idx` },
189
+ create: [
190
+ `CREATE INDEX IF NOT EXISTS ${quote(`${decl.base}_idx`)} ON ${quote(table)} USING bm25 (${[pkColumn, ...columns].map((column) => quote(column)).join(", ")}) WITH (key_field='${pkColumn}');`
191
+ ],
192
+ drop: [`DROP INDEX IF EXISTS ${quote(`${decl.base}_idx`)};`]
193
+ });
194
+ continue;
195
+ }
196
+ if (engine === "fts5") {
197
+ const pkName = primaryKeyFieldOf({ model });
198
+ const pkField = pkName === null ? undefined : model.fields.find((field) => field.name === pkName);
199
+ const pkIsRowidAlias = pkField !== undefined && !pkField.isList && (pkField.type === "Int" || pkField.type === "BigInt");
200
+ if (!pkIsRowidAlias) {
201
+ throw unsupportedCapability({
202
+ dialect: "sqlite",
203
+ feature: `@@search (fts5) on "${modelName}", whose primary key is not a single INTEGER column`,
204
+ hint: "fts5 external-content tables key on the rowid; only an Int/BigInt @id aliases and PINS it \u2014 any other key's implicit rowid can be renumbered by VACUUM, silently mis-linking search hits. Give the searched model an Int/BigInt @id."
205
+ });
206
+ }
207
+ const fts = `${decl.base}_fts`;
208
+ const columnList = columns.map((column) => quote(column)).join(", ");
209
+ const newList = columns.map((column) => `new.${quote(column)}`).join(", ");
210
+ const oldList = columns.map((column) => `old.${quote(column)}`).join(", ");
211
+ artifacts.push({
212
+ id: `search:${modelName}:fts5-table`,
213
+ owns: {
214
+ kind: "table",
215
+ name: fts,
216
+ auxiliaryPrefixes: [`${fts}_`]
217
+ },
218
+ create: [
219
+ `CREATE VIRTUAL TABLE IF NOT EXISTS ${quote(fts)} USING fts5(${columnList}, content='${table}');`
220
+ ],
221
+ drop: [`DROP TABLE IF EXISTS ${quote(fts)};`]
222
+ }, {
223
+ id: `search:${modelName}:fts5-triggers`,
224
+ owns: { kind: "trigger", table, name: `${fts}_ai` },
225
+ create: [
226
+ `CREATE TRIGGER IF NOT EXISTS ${quote(`${fts}_ai`)} AFTER INSERT ON ${quote(table)} BEGIN
227
+ INSERT INTO ${quote(fts)}(rowid, ${columnList}) VALUES (new.rowid, ${newList});
228
+ END;`,
229
+ `CREATE TRIGGER IF NOT EXISTS ${quote(`${fts}_ad`)} AFTER DELETE ON ${quote(table)} BEGIN
230
+ INSERT INTO ${quote(fts)}(${quote(fts)}, rowid, ${columnList}) VALUES ('delete', old.rowid, ${oldList});
231
+ END;`,
232
+ `CREATE TRIGGER IF NOT EXISTS ${quote(`${fts}_au`)} AFTER UPDATE ON ${quote(table)} BEGIN
233
+ INSERT INTO ${quote(fts)}(${quote(fts)}, rowid, ${columnList}) VALUES ('delete', old.rowid, ${oldList});
234
+ INSERT INTO ${quote(fts)}(rowid, ${columnList}) VALUES (new.rowid, ${newList});
235
+ END;`
236
+ ],
237
+ drop: [
238
+ `DROP TRIGGER IF EXISTS ${quote(`${fts}_ai`)};`,
239
+ `DROP TRIGGER IF EXISTS ${quote(`${fts}_ad`)};`,
240
+ `DROP TRIGGER IF EXISTS ${quote(`${fts}_au`)};`
241
+ ]
242
+ });
243
+ continue;
244
+ }
245
+ artifacts.push({
246
+ id: `search:${modelName}:index`,
247
+ owns: { kind: "index", table, name: `${decl.base}_idx` },
248
+ create: [
249
+ `CREATE FULLTEXT INDEX ${quote(`${decl.base}_idx`)} ON ${quote(table)} (${columns.map((column) => quote(column)).join(", ")});`
250
+ ],
251
+ drop: [`DROP INDEX ${quote(`${decl.base}_idx`)} ON ${quote(table)};`]
252
+ });
253
+ }
254
+ return artifacts;
255
+ }
256
+ function highlightColumn(params) {
257
+ const { config, args } = params;
258
+ if (args.highlight === undefined)
259
+ return null;
260
+ const field = config.fields.find((candidate) => candidate.name === args.highlight?.field);
261
+ if (field === undefined) {
262
+ throw new VibeError2({
263
+ code: "VIBE_VALIDATION",
264
+ message: `highlight.field "${args.highlight.field}" is not searched \u2014 declared: ${config.fields.map((candidate) => candidate.name).join(", ")}`,
265
+ meta: { field: args.highlight.field }
266
+ });
267
+ }
268
+ return field.column;
269
+ }
270
+ function shapeHits(params) {
271
+ const { rows, strip, highlightsRequested, engineHasHighlights } = params;
272
+ return rows.map((raw) => {
273
+ const row = { ...raw };
274
+ const score = Number(row[SCORE_ALIAS]);
275
+ const highlight = row[HIGHLIGHT_ALIAS];
276
+ delete row[SCORE_ALIAS];
277
+ delete row[HIGHLIGHT_ALIAS];
278
+ for (const name of strip)
279
+ delete row[name];
280
+ const highlights = engineHasHighlights && highlightsRequested && highlight !== null && highlight !== undefined ? [String(highlight)] : null;
281
+ return { row, score, highlights };
282
+ });
283
+ }
284
+ function createSearchDelegate(config) {
285
+ return (ctx) => {
286
+ const dialect = ctx.dialect;
287
+ const quote = (name) => dialect.quoteIdent({ name });
288
+ const t = quote("t");
289
+ return async (args) => {
290
+ if (typeof args.query !== "string" || args.query.trim().length === 0) {
291
+ throw new VibeError2({
292
+ code: "VIBE_VALIDATION",
293
+ message: "search requires a non-empty query string",
294
+ meta: { table: config.table }
295
+ });
296
+ }
297
+ const take = Math.max(1, Math.trunc(args.take ?? 10));
298
+ const skip = args.skip === undefined ? null : Math.max(0, Math.trunc(args.skip));
299
+ const hlColumn = highlightColumn({ config, args });
300
+ const collector = new ParamCollector(dialect);
301
+ const userWhere = (qualify) => {
302
+ if (args.where === undefined)
303
+ return null;
304
+ const expr = ctx.compileWhere({ where: args.where, qualify });
305
+ return expr === undefined ? null : renderExpr({ dialect, expr, collector });
306
+ };
307
+ let text;
308
+ let strip = [];
309
+ let engineHasHighlights = true;
310
+ if (config.engine === "tsvector") {
311
+ const language = config.language ?? "english";
312
+ const tsv = `${t}.${quote(config.tsvColumn ?? "")}`;
313
+ const tsquery = () => `websearch_to_tsquery('${language}', ${collector.add(args.query)})`;
314
+ text = `SELECT ${t}.*, ts_rank_cd(${tsv}, ${tsquery()}) AS ${quote(SCORE_ALIAS)}`;
315
+ if (hlColumn !== null) {
316
+ text += `, ts_headline('${language}', ${t}.${quote(hlColumn)}, ${tsquery()}) AS ${quote(HIGHLIGHT_ALIAS)}`;
317
+ }
318
+ text += ` FROM ${quote(config.table)} AS ${t} WHERE ${tsv} @@ ${tsquery()}`;
319
+ const where = userWhere("t");
320
+ if (where !== null)
321
+ text += ` AND (${where})`;
322
+ if (args.minScore !== undefined) {
323
+ text += ` AND ts_rank_cd(${tsv}, ${tsquery()}) >= ${collector.add(args.minScore)}`;
324
+ }
325
+ text += ` ORDER BY ${quote(SCORE_ALIAS)} DESC`;
326
+ strip = [config.tsvColumn ?? ""];
327
+ } else if (config.engine === "fts5") {
328
+ const fts = quote(config.ftsTable ?? "");
329
+ text = `SELECT ${t}.*, -bm25(${fts}) AS ${quote(SCORE_ALIAS)}`;
330
+ if (hlColumn !== null) {
331
+ const index = config.fields.findIndex((field) => field.column === hlColumn);
332
+ text += `, snippet(${fts}, ${index}, '<b>', '</b>', '\u2026', 16) AS ${quote(HIGHLIGHT_ALIAS)}`;
333
+ }
334
+ text += ` FROM ${fts} JOIN ${quote(config.table)} AS ${t} ON ${t}.rowid = ${fts}.rowid`;
335
+ text += ` WHERE ${fts} MATCH ${collector.add(args.query)}`;
336
+ const where = userWhere("t");
337
+ if (where !== null)
338
+ text += ` AND (${where})`;
339
+ if (args.minScore !== undefined) {
340
+ text += ` AND -bm25(${fts}) >= ${collector.add(args.minScore)}`;
341
+ }
342
+ text += ` ORDER BY ${quote(SCORE_ALIAS)} DESC`;
343
+ } else if (config.engine === "fulltext") {
344
+ engineHasHighlights = false;
345
+ const match = () => `MATCH (${config.fields.map((field) => `${t}.${quote(field.column)}`).join(", ")}) AGAINST (${collector.add(args.query)} IN NATURAL LANGUAGE MODE)`;
346
+ text = `SELECT ${t}.*, ${match()} AS ${quote(SCORE_ALIAS)} FROM ${quote(config.table)} AS ${t}`;
347
+ text += ` WHERE ${match()}`;
348
+ const where = userWhere("t");
349
+ if (where !== null)
350
+ text += ` AND (${where})`;
351
+ if (args.minScore !== undefined) {
352
+ text += ` AND ${match()} >= ${collector.add(args.minScore)}`;
353
+ }
354
+ text += ` ORDER BY ${quote(SCORE_ALIAS)} DESC`;
355
+ } else {
356
+ const pk = `${t}.${quote(config.pkColumn ?? "")}`;
357
+ text = `SELECT ${t}.*, paradedb.score(${pk}) AS ${quote(SCORE_ALIAS)}`;
358
+ if (hlColumn !== null) {
359
+ text += `, paradedb.snippet(${t}.${quote(hlColumn)}) AS ${quote(HIGHLIGHT_ALIAS)}`;
360
+ }
361
+ text += ` FROM ${quote(config.table)} AS ${t} WHERE (${config.fields.map((field) => `${t}.${quote(field.column)} @@@ ${collector.add(args.query)}`).join(" OR ")})`;
362
+ const where = userWhere("t");
363
+ if (where !== null)
364
+ text += ` AND (${where})`;
365
+ text += ` ORDER BY ${quote(SCORE_ALIAS)} DESC`;
366
+ }
367
+ text += ` LIMIT ${take}`;
368
+ if (skip !== null)
369
+ text += ` OFFSET ${skip}`;
370
+ const rows = await ctx.executeRows({ text, values: collector.values, method: "search" });
371
+ let hits = shapeHits({
372
+ rows,
373
+ strip,
374
+ highlightsRequested: args.highlight !== undefined,
375
+ engineHasHighlights
376
+ });
377
+ if (config.engine === "bm25" && args.minScore !== undefined) {
378
+ const min = args.minScore;
379
+ hits = hits.filter((hit) => hit.score >= min);
380
+ }
381
+ return hits;
382
+ };
383
+ };
384
+ }
385
+ function search(options = {}) {
386
+ return defineExtension({
387
+ name: "search",
388
+ version: VERSION,
389
+ options: { ...options },
390
+ requires: { postgresql: null, pglite: null, sqlite: null, mysql: null },
391
+ schema: {
392
+ modelAttributes: [
393
+ {
394
+ name: "search",
395
+ parse: ({ args, fieldNames }) => {
396
+ const fields = args.fields ?? [];
397
+ if (fields.length === 0) {
398
+ return { error: "@@search requires a field list, e.g. @@search([title, body])" };
399
+ }
400
+ const missing = fields.find((name2) => !fieldNames.includes(name2));
401
+ if (missing !== undefined) {
402
+ return { error: `@@search references unknown field "${missing}"` };
403
+ }
404
+ const name = args.named["name"];
405
+ if (name !== undefined && (typeof name !== "string" || !isSafeSqlName({ name }))) {
406
+ return { error: `name must be a plain identifier` };
407
+ }
408
+ const weights = args.named["weights"];
409
+ if (weights !== undefined) {
410
+ if (!Array.isArray(weights) || weights.length !== fields.length || weights.some((weight) => typeof weight !== "number")) {
411
+ return { error: `weights must be a number list matching the field list` };
412
+ }
413
+ }
414
+ const language = args.named["language"];
415
+ if (language !== undefined && (typeof language !== "string" || !isSafeSqlName({ name: language }))) {
416
+ return { error: `language must be a plain text-search config name` };
417
+ }
418
+ return { declaration: { fields, ...args.named } };
419
+ }
420
+ }
421
+ ],
422
+ indexKinds: ["bm25", "fts5", "fulltext", "gin"]
423
+ },
424
+ migrate: { artifacts: searchArtifacts },
425
+ client: {
426
+ model: ({ model, dialect, provider, options: extOptions, declaration, typeNames }) => {
427
+ const decl = readDeclaration({ model, declaration });
428
+ if (decl === null)
429
+ return null;
430
+ const requested = extOptions["engine"] ?? "auto";
431
+ const engine = resolveSearchEngine({ dialect, provider, requested });
432
+ const language = decl.language ?? extOptions["language"] ?? "english";
433
+ if (!isSafeSqlName({ name: language })) {
434
+ throw new VibeError2({
435
+ code: "VIBE_SCHEMA_INVALID",
436
+ message: `search language "${language}" is not a plain text-search config name`,
437
+ meta: { model: model.name, language }
438
+ });
439
+ }
440
+ const pkField = primaryKeyFieldOf({ model });
441
+ const config = {
442
+ engine,
443
+ table: tableNameOf({ model }),
444
+ fields: decl.fields.map((field) => ({
445
+ name: field,
446
+ column: columnNameOf({ model, field })
447
+ })),
448
+ ...engine === "tsvector" ? { tsvColumn: `${decl.base}_tsv`, language } : {},
449
+ ...engine === "fts5" ? { ftsTable: `${decl.base}_fts` } : {},
450
+ ...engine === "bm25" && pkField !== null ? { pkColumn: columnNameOf({ model, field: pkField }) } : {}
451
+ };
452
+ const fieldUnion = decl.fields.map((field) => JSON.stringify(field)).join(" | ");
453
+ return {
454
+ kind: "method",
455
+ runtimeGlue: `createSearchDelegate(${JSON.stringify(config)})`,
456
+ imports: [{ from: "@vibeorm/extensions/search", names: ["createSearchDelegate"] }],
457
+ dtsImports: [{ from: "@vibeorm/extensions/search", names: ["SearchHit"] }],
458
+ dts: [
459
+ `readonly search: (args: {`,
460
+ ` readonly query: string;`,
461
+ ` readonly where?: ${typeNames.whereInput};`,
462
+ ` readonly take?: number;`,
463
+ ` readonly skip?: number;`,
464
+ ` readonly minScore?: number;`,
465
+ ` readonly highlight?: { readonly field: ${fieldUnion} };`,
466
+ `}) => Promise<SearchHit<${typeNames.payload}>[]>;`
467
+ ]
468
+ };
469
+ }
470
+ },
471
+ doctor: async ({ execute, dialect, options: extOptions }) => {
472
+ if (dialect === "postgres") {
473
+ const requested = extOptions["engine"] ?? "auto";
474
+ if (requested === "bm25") {
475
+ return { checks: [await pgExtensionCheck({ execute, name: "pg_search" })] };
476
+ }
477
+ return { checks: [{ name: "tsvector", ok: true, note: "built in" }] };
478
+ }
479
+ if (dialect === "sqlite") {
480
+ try {
481
+ const rows = await execute({
482
+ text: "SELECT 1 AS ok FROM pragma_compile_options WHERE compile_options = 'ENABLE_FTS5'"
483
+ });
484
+ return {
485
+ checks: [
486
+ rows.length > 0 ? { name: "fts5", ok: true, note: "compiled in" } : { name: "fts5", ok: false, note: "sqlite built without FTS5" }
487
+ ]
488
+ };
489
+ } catch (error) {
490
+ return {
491
+ checks: [
492
+ {
493
+ name: "fts5",
494
+ ok: false,
495
+ note: `check failed: ${error instanceof Error ? error.message : String(error)}`
496
+ }
497
+ ]
498
+ };
499
+ }
500
+ }
501
+ return { checks: [{ name: "fulltext", ok: true, note: "built in (InnoDB FULLTEXT)" }] };
502
+ }
503
+ });
504
+ }
505
+
506
+ // packages/extensions/src/vector/index.ts
507
+ import { VibeError as VibeError3, defineExtension as defineExtension2, unsupportedCapability as unsupportedCapability2 } from "@vibeorm/schema";
508
+ import { ParamCollector as ParamCollector2, getSqlDialect as getSqlDialect2, renderExpr as renderExpr2 } from "@vibeorm/sql";
509
+ var VERSION2 = "2.0.0-alpha.0";
510
+ var METRIC_OPERATOR = {
511
+ cosine: "<=>",
512
+ l2: "<->",
513
+ ip: "<#>"
514
+ };
515
+ var METRIC_OPCLASS = {
516
+ cosine: "vector_cosine_ops",
517
+ l2: "vector_l2_ops",
518
+ ip: "vector_ip_ops"
519
+ };
520
+ var METRICS = ["cosine", "l2", "ip"];
521
+ var INDEX_KINDS = ["hnsw", "ivfflat"];
522
+ var DISTANCE_ALIAS = "_vibe_distance";
523
+ function vectorColumnsOf(params) {
524
+ const columns = params.declaration["columns"];
525
+ if (columns === null || typeof columns !== "object" || Array.isArray(columns))
526
+ return {};
527
+ const out = {};
528
+ for (const [field, spec] of Object.entries(columns)) {
529
+ if (spec === null || typeof spec !== "object")
530
+ continue;
531
+ const dims = spec.dims;
532
+ if (typeof dims === "number")
533
+ out[field] = { dims };
534
+ }
535
+ return out;
536
+ }
537
+ function vectorArtifacts(ctx) {
538
+ if (ctx.dialect !== "postgres") {
539
+ throw unsupportedCapability2({
540
+ dialect: ctx.dialect,
541
+ feature: "the vector extension (pgvector)",
542
+ hint: "pgvector exists on postgres/pglite only (sqlite-vec/mysql are post-2.0)"
543
+ });
544
+ }
545
+ const dialect = getSqlDialect2({ dialect: ctx.dialect });
546
+ const quote = (name) => dialect.quoteIdent({ name });
547
+ const artifacts = [
548
+ {
549
+ id: "vector:extension",
550
+ owns: { kind: "postgresExtension", name: "vector" },
551
+ create: ["CREATE EXTENSION IF NOT EXISTS vector;"],
552
+ drop: ["DROP EXTENSION IF EXISTS vector;"]
553
+ }
554
+ ];
555
+ for (const [modelName, declaration] of Object.entries(ctx.declarations)) {
556
+ const model = ctx.schema.models.find((candidate) => candidate.name === modelName);
557
+ if (model === undefined)
558
+ continue;
559
+ const table = tableNameOf({ model });
560
+ const columns = vectorColumnsOf({ declaration });
561
+ const indexFields = declStringArray(declaration, "fields") ?? [];
562
+ const kindRaw = declString(declaration, "index") ?? "hnsw";
563
+ const metricRaw = declString(declaration, "metric") ?? "cosine";
564
+ const metric = METRICS.find((candidate) => candidate === metricRaw) ?? "cosine";
565
+ for (const field of indexFields) {
566
+ if (columns[field] === undefined) {
567
+ throw new VibeError3({
568
+ code: "VIBE_SCHEMA_INVALID",
569
+ message: `@@vector on "${modelName}" indexes "${field}", which is not a Vector(n) column`,
570
+ meta: { model: modelName, field }
571
+ });
572
+ }
573
+ const column = columnNameOf({ model, field });
574
+ const indexName = `${table}_${column}_${kindRaw}_idx`;
575
+ const withOptions = [];
576
+ const m = declaration["m"];
577
+ const efConstruction = declaration["efConstruction"];
578
+ const lists = declaration["lists"];
579
+ if (kindRaw === "hnsw") {
580
+ if (typeof m === "number")
581
+ withOptions.push(`m = ${m}`);
582
+ if (typeof efConstruction === "number")
583
+ withOptions.push(`ef_construction = ${efConstruction}`);
584
+ } else if (typeof lists === "number") {
585
+ withOptions.push(`lists = ${lists}`);
586
+ }
587
+ const withClause = withOptions.length === 0 ? "" : ` WITH (${withOptions.join(", ")})`;
588
+ artifacts.push({
589
+ id: `vector:${modelName}:${field}`,
590
+ owns: { kind: "index", table, name: indexName },
591
+ create: [
592
+ `CREATE INDEX IF NOT EXISTS ${quote(indexName)} ON ${quote(table)} USING ${kindRaw} (${quote(column)} ${METRIC_OPCLASS[metric]})${withClause};`
593
+ ],
594
+ drop: [`DROP INDEX IF EXISTS ${quote(indexName)};`]
595
+ });
596
+ }
597
+ }
598
+ return artifacts;
599
+ }
600
+ function resolveColumn(params) {
601
+ const { config, args } = params;
602
+ const spec = config.columns[args.by];
603
+ if (spec === undefined) {
604
+ throw new VibeError3({
605
+ code: "VIBE_VALIDATION",
606
+ message: `"${args.by}" is not a Vector(n) column \u2014 declared: ${Object.keys(config.columns).join(", ")}`,
607
+ meta: { by: args.by }
608
+ });
609
+ }
610
+ if (!Array.isArray(args.to) || args.to.some((entry) => typeof entry !== "number" || !Number.isFinite(entry))) {
611
+ throw new VibeError3({
612
+ code: "VIBE_VALIDATION",
613
+ message: `vector "to" must be an array of finite numbers`,
614
+ meta: { by: args.by }
615
+ });
616
+ }
617
+ if (args.to.length !== spec.dims) {
618
+ throw new VibeError3({
619
+ code: "VIBE_VALIDATION",
620
+ message: `vector "to" has ${args.to.length} dimensions, "${args.by}" expects ${spec.dims}`,
621
+ meta: { by: args.by, dims: spec.dims }
622
+ });
623
+ }
624
+ return { column: spec.column, literal: `[${args.to.join(",")}]` };
625
+ }
626
+ function createVectorNamespace(config) {
627
+ return (ctx) => {
628
+ const dialect = ctx.dialect;
629
+ const quote = (name) => dialect.quoteIdent({ name });
630
+ const t = quote("t");
631
+ return {
632
+ nearest: async (args) => {
633
+ const { column, literal } = resolveColumn({ config, args });
634
+ const metric = args.metric ?? config.metric;
635
+ const op = METRIC_OPERATOR[metric];
636
+ const col = `${t}.${quote(column)}`;
637
+ const collector = new ParamCollector2(dialect);
638
+ let text = `SELECT ${t}.*, (${col} ${op} ${collector.add(literal)}) AS ${quote(DISTANCE_ALIAS)}`;
639
+ text += ` FROM ${quote(config.table)} AS ${t}`;
640
+ text += ` WHERE ${col} IS NOT NULL`;
641
+ if (args.where !== undefined) {
642
+ const expr = ctx.compileWhere({ where: args.where, qualify: "t" });
643
+ if (expr !== undefined)
644
+ text += ` AND (${renderExpr2({ dialect, expr, collector })})`;
645
+ }
646
+ if (args.maxDistance !== undefined) {
647
+ text += ` AND (${col} ${op} ${collector.add(literal)}) < ${collector.add(args.maxDistance)}`;
648
+ }
649
+ text += ` ORDER BY ${col} ${op} ${collector.add(literal)} LIMIT ${Math.max(1, Math.trunc(args.take ?? 10))}`;
650
+ const rows = await ctx.executeRows({
651
+ text,
652
+ values: collector.values,
653
+ method: "vector.nearest"
654
+ });
655
+ return rows.map((row) => {
656
+ const { [DISTANCE_ALIAS]: distance, ...rest } = row;
657
+ return { row: rest, distance: Number(distance) };
658
+ });
659
+ },
660
+ set: async (args) => {
661
+ const { column, literal } = resolveColumn({ config, args });
662
+ const collector = new ParamCollector2(dialect);
663
+ let text = `UPDATE ${quote(config.table)} SET ${quote(column)} = ${collector.add(literal)}`;
664
+ const expr = ctx.compileWhere({ where: args.where });
665
+ if (expr !== undefined)
666
+ text += ` WHERE ${renderExpr2({ dialect, expr, collector })}`;
667
+ text += ` RETURNING 1 AS ${quote("_vibe_set")}`;
668
+ const rows = await ctx.executeRows({ text, values: collector.values, method: "vector.set" });
669
+ return rows.length;
670
+ }
671
+ };
672
+ };
673
+ }
674
+ function vector(options = {}) {
675
+ return defineExtension2({
676
+ name: "vector",
677
+ version: VERSION2,
678
+ options: { ...options },
679
+ requires: { postgresql: { extension: "vector" }, pglite: { extension: "vector" } },
680
+ schema: {
681
+ fieldTypes: [
682
+ {
683
+ name: "Vector",
684
+ lower: ({ field, args }) => {
685
+ const dims = Number(args[0]);
686
+ if (!Number.isInteger(dims) || dims <= 0) {
687
+ return { error: "Vector(n) requires a positive integer dimension, e.g. Vector(1536)" };
688
+ }
689
+ return {
690
+ nativeType: `vector(${dims})`,
691
+ declaration: { columns: { [field]: { dims } } }
692
+ };
693
+ }
694
+ }
695
+ ],
696
+ modelAttributes: [
697
+ {
698
+ name: "vector",
699
+ parse: ({ args, fieldNames }) => {
700
+ const fields = args.fields ?? [];
701
+ if (fields.length === 0) {
702
+ return { error: "@@vector requires a field list, e.g. @@vector([embedding])" };
703
+ }
704
+ const missing = fields.find((name) => !fieldNames.includes(name));
705
+ if (missing !== undefined) {
706
+ return { error: `@@vector references unknown field "${missing}"` };
707
+ }
708
+ const metric = args.named["metric"];
709
+ if (metric !== undefined && !METRICS.includes(metric)) {
710
+ return { error: `metric must be one of ${METRICS.join(", ")}` };
711
+ }
712
+ const kind = args.named["index"];
713
+ if (kind !== undefined && !INDEX_KINDS.includes(String(kind))) {
714
+ return { error: `index must be one of ${INDEX_KINDS.join(", ")}` };
715
+ }
716
+ for (const key of ["m", "efConstruction", "lists"]) {
717
+ const value = args.named[key];
718
+ if (value !== undefined && typeof value !== "number") {
719
+ return { error: `${key} must be a number` };
720
+ }
721
+ }
722
+ return { declaration: { fields, ...args.named } };
723
+ }
724
+ }
725
+ ],
726
+ indexKinds: ["hnsw", "ivfflat"]
727
+ },
728
+ migrate: { artifacts: vectorArtifacts },
729
+ client: {
730
+ model: ({ model, dialect, declaration, typeNames }) => {
731
+ const columns = vectorColumnsOf({ declaration });
732
+ const fieldNames = Object.keys(columns);
733
+ if (fieldNames.length === 0)
734
+ return null;
735
+ if (dialect !== "postgres") {
736
+ throw unsupportedCapability2({
737
+ dialect,
738
+ feature: `the vector extension (declared on model "${model.name}")`,
739
+ hint: "pgvector exists on postgres/pglite only"
740
+ });
741
+ }
742
+ const metricRaw = declString(declaration, "metric");
743
+ const metric = METRICS.find((candidate) => candidate === metricRaw) ?? "cosine";
744
+ const config = {
745
+ table: tableNameOf({ model }),
746
+ columns: Object.fromEntries(fieldNames.map((field) => [
747
+ field,
748
+ { column: columnNameOf({ model, field }), dims: columns[field].dims }
749
+ ])),
750
+ metric
751
+ };
752
+ const byUnion = fieldNames.map((name) => JSON.stringify(name)).join(" | ");
753
+ return {
754
+ kind: "namespace",
755
+ runtimeGlue: `createVectorNamespace(${JSON.stringify(config)})`,
756
+ imports: [{ from: "@vibeorm/extensions/vector", names: ["createVectorNamespace"] }],
757
+ dtsImports: [{ from: "@vibeorm/extensions/vector", names: ["VectorHit", "VectorMetric"] }],
758
+ dts: [
759
+ `readonly vector: {`,
760
+ ` readonly nearest: (args: {`,
761
+ ` readonly by: ${byUnion};`,
762
+ ` readonly to: readonly number[];`,
763
+ ` readonly metric?: VectorMetric;`,
764
+ ` readonly take?: number;`,
765
+ ` readonly where?: ${typeNames.whereInput};`,
766
+ ` readonly maxDistance?: number;`,
767
+ ` }) => Promise<VectorHit<${typeNames.payload}>[]>;`,
768
+ ` readonly set: (args: {`,
769
+ ` readonly where: ${typeNames.whereInput};`,
770
+ ` readonly by: ${byUnion};`,
771
+ ` readonly to: readonly number[];`,
772
+ ` }) => Promise<number>;`,
773
+ `};`
774
+ ]
775
+ };
776
+ }
777
+ },
778
+ doctor: async ({ execute, dialect }) => {
779
+ if (dialect !== "postgres") {
780
+ return { checks: [{ name: "vector dialect", ok: false, note: "postgres/pglite only" }] };
781
+ }
782
+ return { checks: [await pgExtensionCheck({ execute, name: "vector" })] };
783
+ }
784
+ });
785
+ }
786
+
787
+ // packages/extensions/src/trgm/index.ts
788
+ import { defineExtension as defineExtension3, unsupportedCapability as unsupportedCapability3 } from "@vibeorm/schema";
789
+ import { getSqlDialect as getSqlDialect3 } from "@vibeorm/sql";
790
+ var DEFAULT_THRESHOLD = 0.3;
791
+ var VERSION3 = "2.0.0-alpha.0";
792
+ function trgmArtifacts(ctx) {
793
+ if (ctx.dialect !== "postgres") {
794
+ throw unsupportedCapability3({
795
+ dialect: ctx.dialect,
796
+ feature: "the trgm extension (pg_trgm)",
797
+ hint: "pg_trgm exists on postgres/pglite only"
798
+ });
799
+ }
800
+ const dialect = getSqlDialect3({ dialect: ctx.dialect });
801
+ const quote = (name) => dialect.quoteIdent({ name });
802
+ const artifacts = [
803
+ {
804
+ id: "trgm:extension",
805
+ owns: { kind: "postgresExtension", name: "pg_trgm" },
806
+ create: ["CREATE EXTENSION IF NOT EXISTS pg_trgm;"],
807
+ drop: ["DROP EXTENSION IF EXISTS pg_trgm;"]
808
+ }
809
+ ];
810
+ for (const [modelName, declaration] of Object.entries(ctx.declarations)) {
811
+ const model = ctx.schema.models.find((candidate) => candidate.name === modelName);
812
+ if (model === undefined)
813
+ continue;
814
+ const table = tableNameOf({ model });
815
+ for (const field of declStringArray(declaration, "fields") ?? []) {
816
+ const column = columnNameOf({ model, field });
817
+ const indexName = `${table}_${column}_trgm_idx`;
818
+ artifacts.push({
819
+ id: `trgm:${modelName}:${field}`,
820
+ owns: { kind: "index", table, name: indexName },
821
+ create: [
822
+ `CREATE INDEX IF NOT EXISTS ${quote(indexName)} ON ${quote(table)} USING gin (${quote(column)} gin_trgm_ops);`
823
+ ],
824
+ drop: [`DROP INDEX IF EXISTS ${quote(indexName)};`]
825
+ });
826
+ }
827
+ }
828
+ return artifacts;
829
+ }
830
+ function trgm(options = {}) {
831
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD;
832
+ return defineExtension3({
833
+ name: "trgm",
834
+ version: VERSION3,
835
+ options: { ...options },
836
+ requires: { postgresql: { extension: "pg_trgm" }, pglite: { extension: "pg_trgm" } },
837
+ schema: {
838
+ modelAttributes: [
839
+ {
840
+ name: "trgm",
841
+ parse: ({ args, fieldNames }) => {
842
+ const fields = args.fields ?? [];
843
+ if (fields.length === 0) {
844
+ return { error: "@@trgm requires a field list, e.g. @@trgm([name])" };
845
+ }
846
+ const missing = fields.find((name) => !fieldNames.includes(name));
847
+ if (missing !== undefined) {
848
+ return { error: `@@trgm references unknown field "${missing}"` };
849
+ }
850
+ return { declaration: { fields } };
851
+ }
852
+ }
853
+ ],
854
+ indexKinds: ["gin_trgm"]
855
+ },
856
+ migrate: { artifacts: trgmArtifacts },
857
+ sql: {
858
+ whereOperators: [
859
+ {
860
+ name: "similarTo",
861
+ compile: ({ column, operand, dialect, options: extOptions }) => {
862
+ if (dialect !== "postgres") {
863
+ throw unsupportedCapability3({
864
+ dialect,
865
+ feature: 'the "similarTo" operator (pg_trgm)',
866
+ hint: "pg_trgm exists on postgres/pglite only"
867
+ });
868
+ }
869
+ const text = typeof operand === "string" ? operand : typeof operand === "object" && operand !== null && ("text" in operand) ? operand.text : undefined;
870
+ if (typeof text !== "string") {
871
+ return { error: "expects a string or { text, threshold? }" };
872
+ }
873
+ const perUse = typeof operand === "object" && operand !== null && "threshold" in operand ? operand.threshold : undefined;
874
+ const configured = extOptions["threshold"];
875
+ const sessionThreshold = typeof configured === "number" ? configured : DEFAULT_THRESHOLD;
876
+ const effective = typeof perUse === "number" ? perUse : sessionThreshold;
877
+ if (effective >= Math.max(sessionThreshold, DEFAULT_THRESHOLD)) {
878
+ return {
879
+ fragment: {
880
+ parts: [
881
+ "(",
882
+ { ident: column },
883
+ " % ",
884
+ { param: text },
885
+ " AND similarity(",
886
+ { ident: column },
887
+ ", ",
888
+ { param: text },
889
+ ") >= ",
890
+ { param: effective },
891
+ ")"
892
+ ]
893
+ }
894
+ };
895
+ }
896
+ return {
897
+ fragment: {
898
+ parts: [
899
+ "similarity(",
900
+ { ident: column },
901
+ ", ",
902
+ { param: text },
903
+ ") >= ",
904
+ { param: effective }
905
+ ]
906
+ }
907
+ };
908
+ }
909
+ }
910
+ ]
911
+ },
912
+ runtime: {
913
+ onConnect: async ({ execute, dialect, options: extOptions }) => {
914
+ if (dialect !== "postgres" || typeof extOptions["threshold"] !== "number")
915
+ return;
916
+ await execute({
917
+ text: `SET pg_trgm.similarity_threshold = ${Number(threshold)}`
918
+ });
919
+ }
920
+ },
921
+ doctor: async ({ execute, dialect }) => {
922
+ if (dialect !== "postgres") {
923
+ return { checks: [{ name: "trgm dialect", ok: false, note: "postgres/pglite only" }] };
924
+ }
925
+ const checks = [await pgExtensionCheck({ execute, name: "pg_trgm" })];
926
+ const floor = Math.max(threshold, DEFAULT_THRESHOLD);
927
+ try {
928
+ const rows = await execute({
929
+ text: `SELECT current_setting('pg_trgm.similarity_threshold', true) AS "threshold"`
930
+ });
931
+ const raw = rows[0]?.["threshold"];
932
+ if (raw === undefined || raw === null || raw === "") {
933
+ checks.push({
934
+ name: "trgm similarity_threshold",
935
+ ok: true,
936
+ note: `not set in this session \u2014 the 0.3 default applies (\u2264 gate floor ${floor})`
937
+ });
938
+ return { checks };
939
+ }
940
+ const server = Number(raw);
941
+ checks.push(Number.isNaN(server) ? { name: "trgm similarity_threshold", ok: false, note: "could not read pg_trgm.similarity_threshold" } : server <= floor ? { name: "trgm similarity_threshold", ok: true, note: `server ${server} \u2264 gate floor ${floor}` } : {
942
+ name: "trgm similarity_threshold",
943
+ ok: false,
944
+ note: `server pg_trgm.similarity_threshold=${server} exceeds the %-gate floor ${floor} \u2014 indexed similarTo queries can drop rows; lower the GUC or raise the extension threshold`
945
+ });
946
+ } catch {
947
+ checks.push({
948
+ name: "trgm similarity_threshold",
949
+ ok: false,
950
+ note: "reading pg_trgm.similarity_threshold failed"
951
+ });
952
+ }
953
+ return { checks };
954
+ }
955
+ });
956
+ }
957
+ export {
958
+ vector,
959
+ trgm,
960
+ search,
961
+ resolveSearchEngine,
962
+ createVectorNamespace,
963
+ createSearchDelegate
964
+ };
965
+
966
+ //# debugId=B1B4930A9A8DBD3964756E2164756E21