@vibeorm/extensions 2.1.1 → 2.2.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,7 +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 number per field), `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 only**), `language` (a text-search config name, tsvector engine only).
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.
41
43
 
42
44
  ```prisma
43
45
  model Post {
@@ -67,6 +69,8 @@ const hits = await db.post.search({
67
69
  // hits: SearchHit<Post>[] — { row, score, highlights }
68
70
  ```
69
71
 
72
+ `take` must be a positive safe integer and `skip` a non-negative safe integer. Negative, fractional and non-finite pagination refuses with `VIBE_VALIDATION` rather than being clamped. `minScore` and vector `maxDistance` must be finite numbers; a negative inner-product distance is valid. Trigram thresholds must be finite numbers in [0, 1]. Vector index tuning requires integers: `m` 2..100, `efConstruction` 4..1000, `lists` 1..32768. The database may impose additional constraints between options.
73
+
70
74
  `highlights` is `readonly string[] | null`; it is `null` when no highlight was requested and on engines without highlighting.
71
75
 
72
76
  ### Engines per dialect
@@ -85,6 +89,10 @@ Two gates worth knowing before you pick a dialect:
85
89
  - `engine: "bm25"` is refused on `pglite` (it does not bundle `pg_search`) and on non-postgres dialects. `"auto"` never selects it, because ParadeDB availability is not knowable at generate time.
86
90
  - The `fts5` engine requires the searched model to have a single `Int` or `BigInt` `@id`. That is the only primary key that aliases and pins the SQLite rowid the external-content table keys on; anything else is refused at generate time.
87
91
 
92
+ ### FTS5 installation on populated tables
93
+
94
+ The installation statements create the external-content table, install its synchronization triggers, then run FTS5's `rebuild` command to index historical rows. Apply the full installation in one transaction. This scans the host table and takes SQLite's write lock; allow for that cost in a migration window. It is installation work, not an application-startup hook. Existing installations are not automatically repaired by upgrading the package; review and run an explicit rebuild migration if historical rows were omitted.
95
+
88
96
  ### Type-ahead (prefix) matching
89
97
 
90
98
  All engines match complete words by default — a user still typing gets nothing for `"piz"`. For search-as-you-type boxes pass `prefix: true`:
package/dist/index.js CHANGED
@@ -1,10 +1,20 @@
1
1
  // @bun
2
2
  // packages/extensions/src/search/index.ts
3
3
  import { VibeError as VibeError2, defineExtension, unsupportedCapability } from "@vibeorm/schema";
4
- import { ParamCollector, getSqlDialect, renderExpr } from "@vibeorm/sql";
5
4
 
6
5
  // packages/extensions/src/shared.ts
7
6
  import { VibeError } from "@vibeorm/schema";
7
+ function finiteNumber(params) {
8
+ const { value, name, integer, min } = params;
9
+ if (typeof value !== "number" || !Number.isFinite(value) || integer === true && !Number.isSafeInteger(value) || min !== undefined && value < min) {
10
+ throw new VibeError({
11
+ code: "VIBE_VALIDATION",
12
+ message: `${name} must be a finite ${integer === true ? "safe integer" : "number"}${min === undefined ? "" : ` >= ${min}`}`,
13
+ meta: { argument: name }
14
+ });
15
+ }
16
+ return value;
17
+ }
8
18
  function tableNameOf(params) {
9
19
  return params.model.dbName ?? params.model.name;
10
20
  }
@@ -74,6 +84,7 @@ async function pgExtensionCheck(params) {
74
84
  }
75
85
 
76
86
  // packages/extensions/src/search/index.ts
87
+ import { ParamCollector, getSqlDialect, renderExpr } from "@vibeorm/sql";
77
88
  var VERSION = "2.0.0-alpha.1";
78
89
  var SCORE_ALIAS = "_vibe_score";
79
90
  var HIGHLIGHT_ALIAS = "_vibe_highlight";
@@ -109,6 +120,9 @@ function readDeclaration(params) {
109
120
  const fields = declStringArray(params.declaration, "fields");
110
121
  if (fields === undefined || fields.length === 0)
111
122
  return null;
123
+ 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
+ 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
+ }
112
126
  return {
113
127
  fields,
114
128
  weights: declNumberArray(params.declaration, "weights"),
@@ -149,6 +163,13 @@ function resolveArtifactNames(params) {
149
163
  }
150
164
  function resolveRank(params) {
151
165
  const { decl, options, engine, dialect } = params;
166
+ if (decl.weights !== undefined && engine !== "tsvector") {
167
+ throw unsupportedCapability({
168
+ dialect,
169
+ feature: `@@search weights on the ${engine} engine`,
170
+ hint: "weights are supported only by tsvector; omit weights for this engine"
171
+ });
172
+ }
152
173
  if (decl.rank !== undefined && engine !== "tsvector") {
153
174
  throw unsupportedCapability({
154
175
  dialect,
@@ -277,7 +298,8 @@ END;`,
277
298
  `CREATE TRIGGER IF NOT EXISTS ${quote(`${fts}_au`)} AFTER UPDATE ON ${quote(table)} BEGIN
278
299
  INSERT INTO ${quote(fts)}(${quote(fts)}, rowid, ${columnList}) VALUES ('delete', old.rowid, ${oldList});
279
300
  INSERT INTO ${quote(fts)}(rowid, ${columnList}) VALUES (new.rowid, ${newList});
280
- END;`
301
+ END;`,
302
+ `INSERT INTO ${quote(fts)}(${quote(fts)}) VALUES ('rebuild');`
281
303
  ],
282
304
  drop: [
283
305
  `DROP TRIGGER IF EXISTS ${quote(`${fts}_ai`)};`,
@@ -340,6 +362,10 @@ function createSearchDelegate(config) {
340
362
  meta: { table: config.table }
341
363
  });
342
364
  }
365
+ const take = finiteNumber({ value: args.take === undefined ? 10 : args.take, name: "take", integer: true, min: 1 });
366
+ const skip = args.skip === undefined ? null : finiteNumber({ value: args.skip, name: "skip", integer: true, min: 0 });
367
+ if (args.minScore !== undefined)
368
+ finiteNumber({ value: args.minScore, name: "minScore" });
343
369
  const prefix = args.prefix === true;
344
370
  if (prefix && config.engine === "bm25") {
345
371
  throw unsupportedCapability({
@@ -355,8 +381,6 @@ function createSearchDelegate(config) {
355
381
  return [];
356
382
  matchText = config.engine === "tsvector" ? tokens.map((token) => `${token}:*`).join(" & ") : config.engine === "fts5" ? tokens.map((token) => `"${token}"*`).join(" ") : tokens.map((token) => `+${token}*`).join(" ");
357
383
  }
358
- const take = Math.max(1, Math.trunc(args.take ?? 10));
359
- const skip = args.skip === undefined ? null : Math.max(0, Math.trunc(args.skip));
360
384
  const hlColumn = highlightColumn({ config, args });
361
385
  const idOnly = args.select === "id";
362
386
  if (idOnly && config.pkColumn === undefined) {
@@ -479,7 +503,7 @@ function search(options = {}) {
479
503
  }
480
504
  const weights = args.named["weights"];
481
505
  if (weights !== undefined) {
482
- if (!Array.isArray(weights) || weights.length !== fields.length || weights.some((weight) => typeof weight !== "number")) {
506
+ if (!Array.isArray(weights) || weights.length !== fields.length || weights.some((weight) => typeof weight !== "number" || !Number.isFinite(weight))) {
483
507
  return { error: `weights must be a number list matching the field list` };
484
508
  }
485
509
  }
@@ -621,6 +645,20 @@ var METRIC_OPCLASS = {
621
645
  var METRICS = ["cosine", "l2", "ip"];
622
646
  var INDEX_KINDS = ["hnsw", "ivfflat"];
623
647
  var DISTANCE_ALIAS = "_vibe_distance";
648
+ var INDEX_BOUNDS = {
649
+ m: [2, 100],
650
+ efConstruction: [4, 1000],
651
+ lists: [1, 32768]
652
+ };
653
+ function indexOptionError(params) {
654
+ for (const [key, [min, max]] of Object.entries(INDEX_BOUNDS)) {
655
+ const value = params.declaration[key];
656
+ if (value !== undefined && (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max)) {
657
+ return `${key} must be an integer between ${min} and ${max}`;
658
+ }
659
+ }
660
+ return;
661
+ }
624
662
  function vectorColumnsOf(params) {
625
663
  const columns = params.declaration["columns"];
626
664
  if (columns === null || typeof columns !== "object" || Array.isArray(columns))
@@ -657,6 +695,10 @@ function vectorArtifacts(ctx) {
657
695
  const model = ctx.schema.models.find((candidate) => candidate.name === modelName);
658
696
  if (model === undefined)
659
697
  continue;
698
+ const invalidOption = indexOptionError({ declaration });
699
+ if (invalidOption !== undefined) {
700
+ throw new VibeError3({ code: "VIBE_SCHEMA_INVALID", message: invalidOption, meta: { model: modelName } });
701
+ }
660
702
  const table = tableNameOf({ model });
661
703
  const columns = vectorColumnsOf({ declaration });
662
704
  const indexFields = declStringArray(declaration, "fields") ?? [];
@@ -732,6 +774,9 @@ function createVectorNamespace(config) {
732
774
  const t = quote("t");
733
775
  return {
734
776
  nearest: async (args) => {
777
+ const take = finiteNumber({ value: args.take === undefined ? 10 : args.take, name: "take", integer: true, min: 1 });
778
+ if (args.maxDistance !== undefined)
779
+ finiteNumber({ value: args.maxDistance, name: "maxDistance" });
735
780
  const { column, literal } = resolveColumn({ config, args });
736
781
  const metric = args.metric ?? config.metric;
737
782
  const op = METRIC_OPERATOR[metric];
@@ -748,7 +793,7 @@ function createVectorNamespace(config) {
748
793
  if (args.maxDistance !== undefined) {
749
794
  text += ` AND (${col} ${op} ${collector.add(literal)}) < ${collector.add(args.maxDistance)}`;
750
795
  }
751
- text += ` ORDER BY ${col} ${op} ${collector.add(literal)} LIMIT ${Math.max(1, Math.trunc(args.take ?? 10))}`;
796
+ text += ` ORDER BY ${col} ${op} ${collector.add(literal)} LIMIT ${take}`;
752
797
  const rows = await ctx.executeRows({
753
798
  text,
754
799
  values: collector.values,
@@ -815,12 +860,9 @@ function vector(options = {}) {
815
860
  if (kind !== undefined && !INDEX_KINDS.includes(String(kind))) {
816
861
  return { error: `index must be one of ${INDEX_KINDS.join(", ")}` };
817
862
  }
818
- for (const key of ["m", "efConstruction", "lists"]) {
819
- const value = args.named[key];
820
- if (value !== undefined && typeof value !== "number") {
821
- return { error: `${key} must be a number` };
822
- }
823
- }
863
+ const invalidOption = indexOptionError({ declaration: args.named });
864
+ if (invalidOption !== undefined)
865
+ return { error: invalidOption };
824
866
  const indexName = args.named["indexName"];
825
867
  if (indexName !== undefined) {
826
868
  if (typeof indexName !== "string" || !isSafeSqlName({ name: indexName })) {
@@ -899,10 +941,13 @@ function vector(options = {}) {
899
941
  }
900
942
 
901
943
  // packages/extensions/src/trgm/index.ts
902
- import { defineExtension as defineExtension3, unsupportedCapability as unsupportedCapability3 } from "@vibeorm/schema";
944
+ import { VibeError as VibeError4, defineExtension as defineExtension3, unsupportedCapability as unsupportedCapability3 } from "@vibeorm/schema";
903
945
  import { getSqlDialect as getSqlDialect3 } from "@vibeorm/sql";
904
946
  var DEFAULT_THRESHOLD = 0.3;
905
947
  var VERSION3 = "2.0.0-alpha.0";
948
+ function validThreshold(value) {
949
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
950
+ }
906
951
  function trgmArtifacts(ctx) {
907
952
  if (ctx.dialect !== "postgres") {
908
953
  throw unsupportedCapability3({
@@ -942,7 +987,10 @@ function trgmArtifacts(ctx) {
942
987
  return artifacts;
943
988
  }
944
989
  function trgm(options = {}) {
945
- const threshold = options.threshold ?? DEFAULT_THRESHOLD;
990
+ const threshold = options.threshold === undefined ? DEFAULT_THRESHOLD : options.threshold;
991
+ if (!validThreshold(threshold)) {
992
+ throw new VibeError4({ code: "VIBE_VALIDATION", message: "trgm threshold must be a finite number between 0 and 1", meta: { argument: "threshold" } });
993
+ }
946
994
  return defineExtension3({
947
995
  name: "trgm",
948
996
  version: VERSION3,
@@ -986,6 +1034,9 @@ function trgm(options = {}) {
986
1034
  }
987
1035
  const perUse = typeof operand === "object" && operand !== null && "threshold" in operand ? operand.threshold : undefined;
988
1036
  const configured = extOptions["threshold"];
1037
+ if (configured !== undefined && !validThreshold(configured) || perUse !== undefined && !validThreshold(perUse)) {
1038
+ return { error: "threshold must be a finite number between 0 and 1" };
1039
+ }
989
1040
  const sessionThreshold = typeof configured === "number" ? configured : DEFAULT_THRESHOLD;
990
1041
  const effective = typeof perUse === "number" ? perUse : sessionThreshold;
991
1042
  if (effective >= Math.max(sessionThreshold, DEFAULT_THRESHOLD)) {
@@ -1077,4 +1128,4 @@ export {
1077
1128
  createSearchDelegate
1078
1129
  };
1079
1130
 
1080
- //# debugId=26711481A38DB0B264756E2164756E21
1131
+ //# debugId=02443E6D9865955B64756E2164756E21