@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@njinlabs/njin",
3
- "version": "0.7.0",
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"],
@@ -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
- const orderBy =
203
- sort && sortableFields.has(sort)
204
- ? `ORDER BY ${sort} ${order === "desc" ? "DESC" : "ASC"}`
205
- : search && config.searchFields.length
206
- ? `ORDER BY (${config.searchFields.map((_, i) => `search::score(${i + 1})`).join(" + ")}) DESC`
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 [data, [countRow]] = await surreal().query<[Returning[], { count: number }[]]>(
220
- `SELECT * FROM ${prefix} ${where} ${orderBy} LIMIT ${pageLimit} START ${start} ${fetch};
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: data ?? [],
241
+ data,
229
242
  meta: {
230
243
  total,
231
244
  page,
@@ -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()) — a `class`
12
- // tokenizer splits on Unicode character-class boundaries (language-agnostic word
13
- // splitting), and the `ngram` filter indexes overlapping 2-10 char slices of each token so
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 class FILTERS lowercase,ngram(2,10);`);
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} SEARCH ANALYZER ${SEARCH_ANALYZER} BM25 HIGHLIGHTS;`,
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
  }