@njinlabs/njin 0.7.0 → 0.7.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/package.json +1 -1
- package/src/core/model/index.ts +22 -9
- package/src/modules/surreal.ts +9 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@njinlabs/njin",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "A modern framework for building company profiles, landing pages, and content-driven websites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": ["bun", "elysia", "surrealdb", "edgejs", "cms", "framework"],
|
package/src/core/model/index.ts
CHANGED
|
@@ -197,14 +197,21 @@ export const makeModel = <Rules extends z.ZodObject>(
|
|
|
197
197
|
// id/createdAt/updatedAt are always present on every record but aren't part of the
|
|
198
198
|
// user-defined schema shape (they're injected in create()/update()) — allow sorting by them too.
|
|
199
199
|
const sortableFields = new Set([...Object.keys(config.schema.shape), "id", "createdAt", "updatedAt"]);
|
|
200
|
+
const hasExplicitSort = Boolean(sort && sortableFields.has(sort));
|
|
200
201
|
// An explicit sort always wins; otherwise, when searching, rank by BM25 relevance
|
|
201
202
|
// (summed across every matched search field) instead of leaving result order unspecified.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
203
|
+
// `ORDER BY` only accepts a bare identifier here, not a function call — so relevance is
|
|
204
|
+
// projected as an aliased field below (SELECT ... AS __relevance) and stripped back out
|
|
205
|
+
// of each returned record afterwards, since it isn't part of the model's schema.
|
|
206
|
+
const useRelevance = !hasExplicitSort && Boolean(search && config.searchFields.length);
|
|
207
|
+
const orderBy = hasExplicitSort
|
|
208
|
+
? `ORDER BY ${sort} ${order === "desc" ? "DESC" : "ASC"}`
|
|
209
|
+
: useRelevance
|
|
210
|
+
? "ORDER BY __relevance DESC"
|
|
211
|
+
: "";
|
|
212
|
+
const relevanceSelect = useRelevance
|
|
213
|
+
? `, (${config.searchFields.map((_, i) => `search::score(${i + 1})`).join(" + ")}) AS __relevance`
|
|
214
|
+
: "";
|
|
208
215
|
|
|
209
216
|
// Validate populate against known relation fields — prevents FETCH injection
|
|
210
217
|
const fetchFields =
|
|
@@ -216,16 +223,22 @@ export const makeModel = <Rules extends z.ZodObject>(
|
|
|
216
223
|
const fetch = fetchFields.length ? `FETCH ${fetchFields.join(", ")}` : "";
|
|
217
224
|
const start = (page - 1) * pageLimit;
|
|
218
225
|
|
|
219
|
-
const [
|
|
220
|
-
`SELECT
|
|
226
|
+
const [rows, [countRow]] = await surreal().query<[(Returning & { __relevance?: number })[], { count: number }[]]>(
|
|
227
|
+
`SELECT *${relevanceSelect} FROM ${prefix} ${where} ${orderBy} LIMIT ${pageLimit} START ${start} ${fetch};
|
|
221
228
|
SELECT count() as count FROM ${prefix} ${where} GROUP ALL`,
|
|
222
229
|
params,
|
|
223
230
|
);
|
|
224
231
|
|
|
232
|
+
const data = (rows ?? []).map((row) => {
|
|
233
|
+
if (!useRelevance) return row;
|
|
234
|
+
const { __relevance, ...rest } = row;
|
|
235
|
+
return rest as Returning;
|
|
236
|
+
});
|
|
237
|
+
|
|
225
238
|
const total = countRow?.count ?? 0;
|
|
226
239
|
|
|
227
240
|
return {
|
|
228
|
-
data
|
|
241
|
+
data,
|
|
229
242
|
meta: {
|
|
230
243
|
total,
|
|
231
244
|
page,
|
package/src/modules/surreal.ts
CHANGED
|
@@ -8,9 +8,11 @@ const EMBEDDED_SCHEMES = ["mem://", "rocksdb://", "surrealkv://"];
|
|
|
8
8
|
|
|
9
9
|
export const isRemotePath = (path: string) => REMOTE_SCHEMES.some((scheme) => path.startsWith(scheme));
|
|
10
10
|
|
|
11
|
-
// Shared by every model's search index (see ../core/model/index.ts's read()) —
|
|
12
|
-
// tokenizer splits on
|
|
13
|
-
//
|
|
11
|
+
// Shared by every model's search index (see ../core/model/index.ts's read()) — `blank`
|
|
12
|
+
// tokenizer splits on whitespace only (unlike `class`, which also splits on punctuation:
|
|
13
|
+
// "Next.js" would become "next" / "." / "js", and a lone "." can't form any 2-char ngram,
|
|
14
|
+
// so a query for "next.js" — tokenized the same way — would never match). The `ngram`
|
|
15
|
+
// filter then indexes overlapping 2-10 char slices of each whitespace-delimited token so
|
|
14
16
|
// the `@N@` match operator can find a term anywhere inside a field (not just a whole-field
|
|
15
17
|
// match) and still tolerate minor typos, similar to trigram search.
|
|
16
18
|
const SEARCH_ANALYZER = "njin_search";
|
|
@@ -36,7 +38,7 @@ const ensureTables = async (db: Surreal) => {
|
|
|
36
38
|
await db.query(`DEFINE TABLE IF NOT EXISTS ${prefix} SCHEMALESS;`);
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
await db.query(`DEFINE ANALYZER IF NOT EXISTS ${SEARCH_ANALYZER} TOKENIZERS
|
|
41
|
+
await db.query(`DEFINE ANALYZER IF NOT EXISTS ${SEARCH_ANALYZER} TOKENIZERS blank FILTERS lowercase,ngram(2,10);`);
|
|
40
42
|
|
|
41
43
|
const definedIndexes = new Set<string>(); // dedupe prefix+field in case two factories share a prefix
|
|
42
44
|
for (const model of models) {
|
|
@@ -45,8 +47,10 @@ const ensureTables = async (db: Surreal) => {
|
|
|
45
47
|
if (definedIndexes.has(key)) continue;
|
|
46
48
|
definedIndexes.add(key);
|
|
47
49
|
|
|
50
|
+
// FULLTEXT, not SEARCH — this SurrealDB version renamed the index-type keyword;
|
|
51
|
+
// SEARCH ANALYZER ... is a parse error here even though older docs/examples use it.
|
|
48
52
|
await db.query(
|
|
49
|
-
`DEFINE INDEX IF NOT EXISTS idx_search_${model.prefix}_${field} ON TABLE ${model.prefix} FIELDS ${field}
|
|
53
|
+
`DEFINE INDEX IF NOT EXISTS idx_search_${model.prefix}_${field} ON TABLE ${model.prefix} FIELDS ${field} FULLTEXT ANALYZER ${SEARCH_ANALYZER} BM25 HIGHLIGHTS;`,
|
|
50
54
|
);
|
|
51
55
|
}
|
|
52
56
|
}
|