@vibeorm/extensions 2.1.1 → 2.2.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/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) {
@@ -469,7 +493,7 @@ function search(options = {}) {
469
493
  if (fields.length === 0) {
470
494
  return { error: "@@search requires a field list, e.g. @@search([title, body])" };
471
495
  }
472
- const missing = fields.find((name2) => !fieldNames.includes(name2));
496
+ const missing = fields.find((name) => !fieldNames.includes(name));
473
497
  if (missing !== undefined) {
474
498
  return { error: `@@search references unknown field "${missing}"` };
475
499
  }
@@ -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
  }
@@ -603,7 +627,6 @@ function search(options = {}) {
603
627
  }
604
628
  });
605
629
  }
606
-
607
630
  // packages/extensions/src/vector/index.ts
608
631
  import { VibeError as VibeError3, defineExtension as defineExtension2, unsupportedCapability as unsupportedCapability2 } from "@vibeorm/schema";
609
632
  import { ParamCollector as ParamCollector2, getSqlDialect as getSqlDialect2, renderExpr as renderExpr2 } from "@vibeorm/sql";
@@ -621,6 +644,20 @@ var METRIC_OPCLASS = {
621
644
  var METRICS = ["cosine", "l2", "ip"];
622
645
  var INDEX_KINDS = ["hnsw", "ivfflat"];
623
646
  var DISTANCE_ALIAS = "_vibe_distance";
647
+ var INDEX_BOUNDS = {
648
+ m: [2, 100],
649
+ efConstruction: [4, 1000],
650
+ lists: [1, 32768]
651
+ };
652
+ function indexOptionError(params) {
653
+ for (const [key, [min, max]] of Object.entries(INDEX_BOUNDS)) {
654
+ const value = params.declaration[key];
655
+ if (value !== undefined && (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max)) {
656
+ return `${key} must be an integer between ${min} and ${max}`;
657
+ }
658
+ }
659
+ return;
660
+ }
624
661
  function vectorColumnsOf(params) {
625
662
  const columns = params.declaration["columns"];
626
663
  if (columns === null || typeof columns !== "object" || Array.isArray(columns))
@@ -657,6 +694,10 @@ function vectorArtifacts(ctx) {
657
694
  const model = ctx.schema.models.find((candidate) => candidate.name === modelName);
658
695
  if (model === undefined)
659
696
  continue;
697
+ const invalidOption = indexOptionError({ declaration });
698
+ if (invalidOption !== undefined) {
699
+ throw new VibeError3({ code: "VIBE_SCHEMA_INVALID", message: invalidOption, meta: { model: modelName } });
700
+ }
660
701
  const table = tableNameOf({ model });
661
702
  const columns = vectorColumnsOf({ declaration });
662
703
  const indexFields = declStringArray(declaration, "fields") ?? [];
@@ -732,6 +773,9 @@ function createVectorNamespace(config) {
732
773
  const t = quote("t");
733
774
  return {
734
775
  nearest: async (args) => {
776
+ const take = finiteNumber({ value: args.take === undefined ? 10 : args.take, name: "take", integer: true, min: 1 });
777
+ if (args.maxDistance !== undefined)
778
+ finiteNumber({ value: args.maxDistance, name: "maxDistance" });
735
779
  const { column, literal } = resolveColumn({ config, args });
736
780
  const metric = args.metric ?? config.metric;
737
781
  const op = METRIC_OPERATOR[metric];
@@ -748,7 +792,7 @@ function createVectorNamespace(config) {
748
792
  if (args.maxDistance !== undefined) {
749
793
  text += ` AND (${col} ${op} ${collector.add(literal)}) < ${collector.add(args.maxDistance)}`;
750
794
  }
751
- text += ` ORDER BY ${col} ${op} ${collector.add(literal)} LIMIT ${Math.max(1, Math.trunc(args.take ?? 10))}`;
795
+ text += ` ORDER BY ${col} ${op} ${collector.add(literal)} LIMIT ${take}`;
752
796
  const rows = await ctx.executeRows({
753
797
  text,
754
798
  values: collector.values,
@@ -815,12 +859,9 @@ function vector(options = {}) {
815
859
  if (kind !== undefined && !INDEX_KINDS.includes(String(kind))) {
816
860
  return { error: `index must be one of ${INDEX_KINDS.join(", ")}` };
817
861
  }
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
- }
862
+ const invalidOption = indexOptionError({ declaration: args.named });
863
+ if (invalidOption !== undefined)
864
+ return { error: invalidOption };
824
865
  const indexName = args.named["indexName"];
825
866
  if (indexName !== undefined) {
826
867
  if (typeof indexName !== "string" || !isSafeSqlName({ name: indexName })) {
@@ -897,12 +938,14 @@ function vector(options = {}) {
897
938
  }
898
939
  });
899
940
  }
900
-
901
941
  // packages/extensions/src/trgm/index.ts
902
- import { defineExtension as defineExtension3, unsupportedCapability as unsupportedCapability3 } from "@vibeorm/schema";
942
+ import { VibeError as VibeError4, defineExtension as defineExtension3, unsupportedCapability as unsupportedCapability3 } from "@vibeorm/schema";
903
943
  import { getSqlDialect as getSqlDialect3 } from "@vibeorm/sql";
904
944
  var DEFAULT_THRESHOLD = 0.3;
905
945
  var VERSION3 = "2.0.0-alpha.0";
946
+ function validThreshold(value) {
947
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
948
+ }
906
949
  function trgmArtifacts(ctx) {
907
950
  if (ctx.dialect !== "postgres") {
908
951
  throw unsupportedCapability3({
@@ -942,7 +985,10 @@ function trgmArtifacts(ctx) {
942
985
  return artifacts;
943
986
  }
944
987
  function trgm(options = {}) {
945
- const threshold = options.threshold ?? DEFAULT_THRESHOLD;
988
+ const threshold = options.threshold === undefined ? DEFAULT_THRESHOLD : options.threshold;
989
+ if (!validThreshold(threshold)) {
990
+ throw new VibeError4({ code: "VIBE_VALIDATION", message: "trgm threshold must be a finite number between 0 and 1", meta: { argument: "threshold" } });
991
+ }
946
992
  return defineExtension3({
947
993
  name: "trgm",
948
994
  version: VERSION3,
@@ -986,6 +1032,9 @@ function trgm(options = {}) {
986
1032
  }
987
1033
  const perUse = typeof operand === "object" && operand !== null && "threshold" in operand ? operand.threshold : undefined;
988
1034
  const configured = extOptions["threshold"];
1035
+ if (configured !== undefined && !validThreshold(configured) || perUse !== undefined && !validThreshold(perUse)) {
1036
+ return { error: "threshold must be a finite number between 0 and 1" };
1037
+ }
989
1038
  const sessionThreshold = typeof configured === "number" ? configured : DEFAULT_THRESHOLD;
990
1039
  const effective = typeof perUse === "number" ? perUse : sessionThreshold;
991
1040
  if (effective >= Math.max(sessionThreshold, DEFAULT_THRESHOLD)) {
@@ -1069,12 +1118,12 @@ function trgm(options = {}) {
1069
1118
  });
1070
1119
  }
1071
1120
  export {
1072
- vector,
1073
- trgm,
1074
- search,
1075
- resolveSearchEngine,
1121
+ createSearchDelegate,
1076
1122
  createVectorNamespace,
1077
- createSearchDelegate
1123
+ resolveSearchEngine,
1124
+ search,
1125
+ trgm,
1126
+ vector
1078
1127
  };
1079
1128
 
1080
- //# debugId=26711481A38DB0B264756E2164756E21
1129
+ //# debugId=CB654FBC580AA6A164756E2164756E21