@njinlabs/njin 0.6.1 → 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.6.1",
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"],
@@ -7,6 +7,7 @@ import { runAfterHooks, runBeforeDestroyHooks, runBeforeHooks } from "./hooks";
7
7
  export type FormMeta = {
8
8
  label: string;
9
9
  unique?: boolean;
10
+ hideForm?: boolean;
10
11
  };
11
12
 
12
13
  export class UniqueConstraintError extends Error {
@@ -153,10 +154,16 @@ export const makeModel = <Rules extends z.ZodObject>(
153
154
  const whereParts: string[] = [];
154
155
  const params: Record<string, unknown> = {};
155
156
 
157
+ // Uses SurrealDB's full-text SEARCH index (see SEARCH_ANALYZER in ../../modules/surreal),
158
+ // not string::similarity::jaro_winkler — jaro_winkler compares two strings as a whole, so
159
+ // a short search term against a long field (e.g. "next.js" inside a full title) scores far
160
+ // below any usable threshold even though the term is clearly present. The @N@ match
161
+ // operator + ngram analyzer gives real substring/partial-word matching, BM25 relevance
162
+ // ranking, and still tolerates minor typos via shared n-grams.
156
163
  if (search && config.searchFields.length) {
157
- params.search = search.toLowerCase();
164
+ params.search = search.trim();
158
165
  whereParts.push(
159
- `(${config.searchFields.map((f) => `string::similarity::jaro_winkler(string::lowercase(${f}), $search) > 0.4`).join(" OR ")})`,
166
+ `(${config.searchFields.map((f, i) => `${f} @${i + 1}@ $search`).join(" OR ")})`,
160
167
  );
161
168
  }
162
169
 
@@ -190,7 +197,21 @@ export const makeModel = <Rules extends z.ZodObject>(
190
197
  // id/createdAt/updatedAt are always present on every record but aren't part of the
191
198
  // user-defined schema shape (they're injected in create()/update()) — allow sorting by them too.
192
199
  const sortableFields = new Set([...Object.keys(config.schema.shape), "id", "createdAt", "updatedAt"]);
193
- const orderBy = sort && sortableFields.has(sort) ? `ORDER BY ${sort} ${order === "desc" ? "DESC" : "ASC"}` : "";
200
+ const hasExplicitSort = Boolean(sort && sortableFields.has(sort));
201
+ // An explicit sort always wins; otherwise, when searching, rank by BM25 relevance
202
+ // (summed across every matched search field) instead of leaving result order unspecified.
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
+ : "";
194
215
 
195
216
  // Validate populate against known relation fields — prevents FETCH injection
196
217
  const fetchFields =
@@ -202,16 +223,22 @@ export const makeModel = <Rules extends z.ZodObject>(
202
223
  const fetch = fetchFields.length ? `FETCH ${fetchFields.join(", ")}` : "";
203
224
  const start = (page - 1) * pageLimit;
204
225
 
205
- const [data, [countRow]] = await surreal().query<[Returning[], { count: number }[]]>(
206
- `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};
207
228
  SELECT count() as count FROM ${prefix} ${where} GROUP ALL`,
208
229
  params,
209
230
  );
210
231
 
232
+ const data = (rows ?? []).map((row) => {
233
+ if (!useRelevance) return row;
234
+ const { __relevance, ...rest } = row;
235
+ return rest as Returning;
236
+ });
237
+
211
238
  const total = countRow?.count ?? 0;
212
239
 
213
240
  return {
214
- data: data ?? [],
241
+ data,
215
242
  meta: {
216
243
  total,
217
244
  page,
@@ -262,6 +289,7 @@ export const makeModel = <Rules extends z.ZodObject>(
262
289
  create,
263
290
  destroy,
264
291
  read,
292
+ searchFields: config.searchFields,
265
293
  show,
266
294
  table,
267
295
  update,
@@ -5,11 +5,33 @@ import z from "zod";
5
5
  import auth from "./auth";
6
6
  import elysia from "./elysia";
7
7
 
8
+ // Removes fields marked hideForm: true from a JSON schema's properties/required,
9
+ // recursing into nested object/array shapes so hidden fields never reach the admin panel.
10
+ const stripHiddenFields = (node: any): void => {
11
+ if (!node || typeof node !== "object") return;
12
+
13
+ if (node.properties) {
14
+ for (const [key, prop] of Object.entries(node.properties as Record<string, any>)) {
15
+ if (prop?.hideForm) {
16
+ delete node.properties[key];
17
+ if (Array.isArray(node.required)) {
18
+ node.required = node.required.filter((r: string) => r !== key);
19
+ }
20
+ } else {
21
+ stripHiddenFields(prop);
22
+ }
23
+ }
24
+ }
25
+
26
+ if (node.items) stripHiddenFields(node.items);
27
+ };
28
+
8
29
  // Shared by both models and vars groups on the /api/schema endpoint — strips
9
30
  // non-JSON-representable renderAs types (relation/multi_relation/file) into
10
- // plain JSON-schema shapes the admin panel can render a form from.
11
- const toAdminSchema = (schema: z.ZodObject) =>
12
- schema.toJSONSchema({
31
+ // plain JSON-schema shapes the admin panel can render a form from, and drops
32
+ // any field marked hideForm: true so it never reaches the admin panel.
33
+ const toAdminSchema = (schema: z.ZodObject) => {
34
+ const jsonSchema = schema.toJSONSchema({
13
35
  unrepresentable: "any",
14
36
  override: (ctx) => {
15
37
  if (ctx.jsonSchema.renderAs === "relation") {
@@ -29,6 +51,11 @@ const toAdminSchema = (schema: z.ZodObject) =>
29
51
  },
30
52
  });
31
53
 
54
+ stripHiddenFields(jsonSchema);
55
+
56
+ return jsonSchema;
57
+ };
58
+
32
59
  const api = makeModule(() => {
33
60
  const fn = () => {};
34
61
 
@@ -8,6 +8,15 @@ 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()) — `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
16
+ // the `@N@` match operator can find a term anywhere inside a field (not just a whole-field
17
+ // match) and still tolerate minor typos, similar to trigram search.
18
+ const SEARCH_ANALYZER = "njin_search";
19
+
11
20
  // SurrealDB's `GROUP ALL` aggregate (used for count queries) throws NotFoundError
12
21
  // on a table that has never had a row created, unlike plain SELECT. Defining every
13
22
  // table up front avoids that first-run failure (e.g. /api/setup/status before any user exists).
@@ -15,16 +24,36 @@ const ensureTables = async (db: Surreal) => {
15
24
  const { default: userModel } = await import("../models/user");
16
25
  const { default: fileModel } = await import("../models/file");
17
26
 
18
- const prefixes = new Set<string>([userModel.prefix, fileModel.prefix, "vars"]);
27
+ const models: { prefix: string; searchFields?: string[] }[] = [userModel, fileModel];
19
28
 
20
29
  for (const factory of getConfig().models) {
21
30
  const { default: model } = await factory();
22
- prefixes.add(model.prefix);
31
+ models.push(model);
23
32
  }
24
33
 
34
+ const prefixes = new Set<string>(models.map((model) => model.prefix));
35
+ prefixes.add("vars");
36
+
25
37
  for (const prefix of prefixes) {
26
38
  await db.query(`DEFINE TABLE IF NOT EXISTS ${prefix} SCHEMALESS;`);
27
39
  }
40
+
41
+ await db.query(`DEFINE ANALYZER IF NOT EXISTS ${SEARCH_ANALYZER} TOKENIZERS blank FILTERS lowercase,ngram(2,10);`);
42
+
43
+ const definedIndexes = new Set<string>(); // dedupe prefix+field in case two factories share a prefix
44
+ for (const model of models) {
45
+ for (const field of model.searchFields ?? []) {
46
+ const key = `${model.prefix}.${field}`;
47
+ if (definedIndexes.has(key)) continue;
48
+ definedIndexes.add(key);
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.
52
+ await db.query(
53
+ `DEFINE INDEX IF NOT EXISTS idx_search_${model.prefix}_${field} ON TABLE ${model.prefix} FIELDS ${field} FULLTEXT ANALYZER ${SEARCH_ANALYZER} BM25 HIGHLIGHTS;`,
54
+ );
55
+ }
56
+ }
28
57
  };
29
58
 
30
59
  const surreal = makeModule(() => {