@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 +9 -1
- package/dist/index.js +66 -15
- package/dist/index.js.map +6 -6
- package/dist/search/index.d.ts.map +1 -1
- package/dist/search/index.js +30 -6
- package/dist/search/index.js.map +4 -4
- package/dist/shared.d.ts +7 -0
- package/dist/shared.d.ts.map +1 -1
- package/dist/trgm/index.d.ts.map +1 -1
- package/dist/trgm/index.js +23 -3
- package/dist/trgm/index.js.map +4 -4
- package/dist/vector/index.d.ts.map +1 -1
- package/dist/vector/index.js +37 -8
- package/dist/vector/index.js.map +4 -4
- package/package.json +4 -4
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 ${
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
|
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=
|
|
1131
|
+
//# debugId=02443E6D9865955B64756E2164756E21
|