@njinlabs/njin 0.7.0 → 0.8.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 CHANGED
@@ -63,6 +63,11 @@ const post = makeModel("post", {
63
63
  export default post;
64
64
  ```
65
65
 
66
+ `searchFields` can also reach one level into a `relation`/`multiRelation`/`file`/`multiFile` field
67
+ using dot notation, e.g. `searchFields: ["title", "author.name"]` to search `post` by its related
68
+ `author`'s `name` field. A bad reference (the local field isn't a relation, or more than one level
69
+ of nesting is used) throws when `makeModel()` is called, not at query time.
70
+
66
71
  Register it in `config.ts` at the project root:
67
72
 
68
73
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@njinlabs/njin",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
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"],
@@ -63,6 +63,56 @@ const OPERATORS: Record<FilterOperator, (field: string, param: string) => string
63
63
  $in: (f, p) => `${f} CONTAINS $${p}`,
64
64
  };
65
65
 
66
+ const RELATION_RENDER_AS = ["relation", "multi_relation", "file", "multi_file"];
67
+
68
+ // Every makeModel() call registers its own schema here before returning — since
69
+ // relation()/relationMany() require the actual built target Model as an argument,
70
+ // the target's makeModel() is guaranteed to have already run (and registered itself)
71
+ // by the time a field referencing it via relation() can even be constructed. Used
72
+ // only for the opportunistic target-field check in resolveSearchPlan below.
73
+ const schemaRegistry = new Map<string, z.ZodObject>();
74
+
75
+ export type ResolvedSearchField =
76
+ | { kind: "flat"; field: string }
77
+ | { kind: "nested"; local: string; targetPrefix: string; targetField: string; multi: boolean };
78
+
79
+ // Parses searchFields entries into flat field names and single-hop nested
80
+ // "relationField.targetField" references, validating relation-family fields and
81
+ // (when the target model has already registered itself) the target field's existence
82
+ // synchronously — so a bad reference throws at model-definition time, not per-request.
83
+ export const resolveSearchPlan = (schema: z.ZodObject, searchFields: string[], modelName: string): ResolvedSearchField[] => {
84
+ return searchFields.map((raw): ResolvedSearchField => {
85
+ const dot = raw.indexOf(".");
86
+ if (dot === -1) return { kind: "flat", field: raw };
87
+
88
+ if (raw.indexOf(".", dot + 1) !== -1) {
89
+ throw new Error(`Model "${modelName}": searchFields entry "${raw}" has more than one level of nesting — only a single relation hop is supported.`);
90
+ }
91
+
92
+ const local = raw.slice(0, dot);
93
+ const targetField = raw.slice(dot + 1);
94
+ const meta = (schema.shape[local] as z.ZodType | undefined)?.meta() as any;
95
+
96
+ if (!meta || !RELATION_RENDER_AS.includes(meta.renderAs)) {
97
+ throw new Error(`Model "${modelName}": searchFields entry "${raw}" references "${local}", which is not a relation/file field on this model's schema.`);
98
+ }
99
+
100
+ const targetPrefix = meta.model as string;
101
+ const targetSchema = schemaRegistry.get(targetPrefix);
102
+ if (targetSchema && !(targetField in targetSchema.shape)) {
103
+ throw new Error(`Model "${modelName}": searchFields entry "${raw}" references field "${targetField}", which does not exist on model "${targetPrefix}".`);
104
+ }
105
+
106
+ return {
107
+ kind: "nested",
108
+ local,
109
+ targetPrefix,
110
+ targetField,
111
+ multi: meta.renderAs === "multi_relation" || meta.renderAs === "multi_file",
112
+ };
113
+ });
114
+ };
115
+
66
116
  export const makeModel = <Rules extends z.ZodObject>(
67
117
  prefix: string,
68
118
  config: {
@@ -80,15 +130,19 @@ export const makeModel = <Rules extends z.ZodObject>(
80
130
 
81
131
  const table = new Table(prefix);
82
132
 
133
+ schemaRegistry.set(prefix, config.schema);
134
+
83
135
  const relationFields = Object.entries(config.schema.shape)
84
136
  .filter(([, v]) => {
85
137
  const m = (v as z.ZodType).meta() as any;
86
- return ["relation", "multi_relation", "file", "multi_file"].includes(m?.renderAs);
138
+ return RELATION_RENDER_AS.includes(m?.renderAs);
87
139
  })
88
140
  .map(([k]) => k);
89
141
 
90
142
  const relationFieldSet = new Set(relationFields);
91
143
 
144
+ const searchPlan = resolveSearchPlan(config.schema, config.searchFields, config.name);
145
+
92
146
  const uniqueFields = Object.entries(config.schema.shape)
93
147
  .filter(([, v]) => (v as z.ZodType).meta()?.unique === true)
94
148
  .map(([k]) => k);
@@ -160,11 +214,21 @@ export const makeModel = <Rules extends z.ZodObject>(
160
214
  // below any usable threshold even though the term is clearly present. The @N@ match
161
215
  // operator + ngram analyzer gives real substring/partial-word matching, BM25 relevance
162
216
  // ranking, and still tolerates minor typos via shared n-grams.
163
- if (search && config.searchFields.length) {
217
+ //
218
+ // A nested entry (searchPlan `kind: "nested"`, e.g. "author.name") becomes an IN/CONTAINSANY
219
+ // subquery against the target table instead — the local relation field holds a record link,
220
+ // not text, so it can't carry its own full-text index. All entries (flat and nested alike)
221
+ // share one running @N@ counter in declaration order, since SurrealDB's match-ref scoping
222
+ // across a WHERE-clause subquery on a different table isn't something to assume either way.
223
+ if (search && searchPlan.length) {
164
224
  params.search = search.trim();
165
- whereParts.push(
166
- `(${config.searchFields.map((f, i) => `${f} @${i + 1}@ $search`).join(" OR ")})`,
167
- );
225
+ const clauses = searchPlan.map((entry, i) => {
226
+ const n = i + 1;
227
+ if (entry.kind === "flat") return `${entry.field} @${n}@ $search`;
228
+ const op = entry.multi ? "CONTAINSANY" : "IN";
229
+ return `${entry.local} ${op} (SELECT VALUE id FROM ${entry.targetPrefix} WHERE ${entry.targetField} @${n}@ $search)`;
230
+ });
231
+ whereParts.push(`(${clauses.join(" OR ")})`);
168
232
  }
169
233
 
170
234
  if (filters) {
@@ -197,14 +261,28 @@ export const makeModel = <Rules extends z.ZodObject>(
197
261
  // id/createdAt/updatedAt are always present on every record but aren't part of the
198
262
  // user-defined schema shape (they're injected in create()/update()) — allow sorting by them too.
199
263
  const sortableFields = new Set([...Object.keys(config.schema.shape), "id", "createdAt", "updatedAt"]);
264
+ const hasExplicitSort = Boolean(sort && sortableFields.has(sort));
200
265
  // An explicit sort always wins; otherwise, when searching, rank by BM25 relevance
201
- // (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
- : "";
266
+ // (summed across every matched flat search field) instead of leaving result order
267
+ // unspecified. Nested (relation) entries are excluded from this sum — a match found via
268
+ // the IN/CONTAINSANY subquery above has no per-record score in this query's context, since
269
+ // it happened on a different table entirely. If a model's searchFields are all nested,
270
+ // there's no score to rank by, so relevance ordering is skipped (same as no searchFields).
271
+ // `ORDER BY` only accepts a bare identifier here, not a function call — so relevance is
272
+ // projected as an aliased field below (SELECT ... AS __relevance) and stripped back out
273
+ // of each returned record afterwards, since it isn't part of the model's schema.
274
+ const useRelevance = !hasExplicitSort && Boolean(search && searchPlan.some((e) => e.kind === "flat"));
275
+ const orderBy = hasExplicitSort
276
+ ? `ORDER BY ${sort} ${order === "desc" ? "DESC" : "ASC"}`
277
+ : useRelevance
278
+ ? "ORDER BY __relevance DESC"
279
+ : "";
280
+ const relevanceSelect = useRelevance
281
+ ? `, (${searchPlan
282
+ .map((e, i) => (e.kind === "flat" ? `search::score(${i + 1})` : null))
283
+ .filter((s): s is string => s !== null)
284
+ .join(" + ")}) AS __relevance`
285
+ : "";
208
286
 
209
287
  // Validate populate against known relation fields — prevents FETCH injection
210
288
  const fetchFields =
@@ -216,16 +294,22 @@ export const makeModel = <Rules extends z.ZodObject>(
216
294
  const fetch = fetchFields.length ? `FETCH ${fetchFields.join(", ")}` : "";
217
295
  const start = (page - 1) * pageLimit;
218
296
 
219
- const [data, [countRow]] = await surreal().query<[Returning[], { count: number }[]]>(
220
- `SELECT * FROM ${prefix} ${where} ${orderBy} LIMIT ${pageLimit} START ${start} ${fetch};
297
+ const [rows, [countRow]] = await surreal().query<[(Returning & { __relevance?: number })[], { count: number }[]]>(
298
+ `SELECT *${relevanceSelect} FROM ${prefix} ${where} ${orderBy} LIMIT ${pageLimit} START ${start} ${fetch};
221
299
  SELECT count() as count FROM ${prefix} ${where} GROUP ALL`,
222
300
  params,
223
301
  );
224
302
 
303
+ const data = (rows ?? []).map((row) => {
304
+ if (!useRelevance) return row;
305
+ const { __relevance, ...rest } = row;
306
+ return rest as Returning;
307
+ });
308
+
225
309
  const total = countRow?.count ?? 0;
226
310
 
227
311
  return {
228
- data: data ?? [],
312
+ data,
229
313
  meta: {
230
314
  total,
231
315
  page,
@@ -3,7 +3,7 @@ import type { makeModel } from "../core/model";
3
3
  import { makeModule } from "../core/module";
4
4
  import { resolveSafePath } from "../core/path_guard";
5
5
  import Elysia from "elysia";
6
- import { join, resolve } from "node:path";
6
+ import { resolve } from "node:path";
7
7
  import { RecordId } from "surrealdb";
8
8
  import z from "zod";
9
9
  import auth from "./auth";
@@ -1,16 +1,20 @@
1
1
  import { getConfig } from "../core/config";
2
+ import { resolveSearchPlan } from "../core/model";
2
3
  import { makeModule } from "../core/module";
3
4
  import { Surreal, createRemoteEngines } from "surrealdb";
4
5
  import type { Engines } from "surrealdb";
6
+ import type { z } from "zod";
5
7
 
6
8
  const REMOTE_SCHEMES = ["ws://", "wss://", "http://", "https://"];
7
9
  const EMBEDDED_SCHEMES = ["mem://", "rocksdb://", "surrealkv://"];
8
10
 
9
11
  export const isRemotePath = (path: string) => REMOTE_SCHEMES.some((scheme) => path.startsWith(scheme));
10
12
 
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
13
+ // Shared by every model's search index (see ../core/model/index.ts's read()) — `blank`
14
+ // tokenizer splits on whitespace only (unlike `class`, which also splits on punctuation:
15
+ // "Next.js" would become "next" / "." / "js", and a lone "." can't form any 2-char ngram,
16
+ // so a query for "next.js" — tokenized the same way — would never match). The `ngram`
17
+ // filter then indexes overlapping 2-10 char slices of each whitespace-delimited token so
14
18
  // the `@N@` match operator can find a term anywhere inside a field (not just a whole-field
15
19
  // match) and still tolerate minor typos, similar to trigram search.
16
20
  const SEARCH_ANALYZER = "njin_search";
@@ -22,7 +26,7 @@ const ensureTables = async (db: Surreal) => {
22
26
  const { default: userModel } = await import("../models/user");
23
27
  const { default: fileModel } = await import("../models/file");
24
28
 
25
- const models: { prefix: string; searchFields?: string[] }[] = [userModel, fileModel];
29
+ const models: { prefix: string; searchFields?: string[]; validation?: z.ZodObject }[] = [userModel, fileModel];
26
30
 
27
31
  for (const factory of getConfig().models) {
28
32
  const { default: model } = await factory();
@@ -36,18 +40,38 @@ const ensureTables = async (db: Surreal) => {
36
40
  await db.query(`DEFINE TABLE IF NOT EXISTS ${prefix} SCHEMALESS;`);
37
41
  }
38
42
 
39
- await db.query(`DEFINE ANALYZER IF NOT EXISTS ${SEARCH_ANALYZER} TOKENIZERS class FILTERS lowercase,ngram(2,10);`);
43
+ await db.query(`DEFINE ANALYZER IF NOT EXISTS ${SEARCH_ANALYZER} TOKENIZERS blank FILTERS lowercase,ngram(2,10);`);
40
44
 
41
- const definedIndexes = new Set<string>(); // dedupe prefix+field in case two factories share a prefix
42
- for (const model of models) {
43
- for (const field of model.searchFields ?? []) {
44
- const key = `${model.prefix}.${field}`;
45
- if (definedIndexes.has(key)) continue;
46
- definedIndexes.add(key);
45
+ const defineSearchIndex = async (targetPrefix: string, field: string) => {
46
+ const key = `${targetPrefix}.${field}`;
47
+ if (definedIndexes.has(key)) return;
48
+ definedIndexes.add(key);
47
49
 
48
- 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;`,
50
- );
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_${targetPrefix}_${field} ON TABLE ${targetPrefix} FIELDS ${field} FULLTEXT ANALYZER ${SEARCH_ANALYZER} BM25 HIGHLIGHTS;`,
54
+ );
55
+ };
56
+
57
+ const definedIndexes = new Set<string>(); // dedupe prefix+field in case two factories share a prefix, or a nested reference targets an already-indexed field
58
+ for (const model of models) {
59
+ // A nested searchFields entry (e.g. "author.name") needs its index defined on the
60
+ // *target* table/field instead — the local relation field holds a record link, not
61
+ // text. model.validation carries the schema needed to resolve that; if it's missing for
62
+ // some reason, fall back to treating every entry as flat (today's behavior) rather than
63
+ // throwing here — an authoring bug in a dotted entry is makeModel()'s job to catch, not
64
+ // table setup's.
65
+ const plan = model.validation
66
+ ? resolveSearchPlan(model.validation, model.searchFields ?? [], model.prefix)
67
+ : (model.searchFields ?? []).map((field) => ({ kind: "flat" as const, field }));
68
+
69
+ for (const entry of plan) {
70
+ if (entry.kind === "flat") {
71
+ await defineSearchIndex(model.prefix, entry.field);
72
+ } else {
73
+ await defineSearchIndex(entry.targetPrefix, entry.targetField);
74
+ }
51
75
  }
52
76
  }
53
77
  };