@njinlabs/njin 0.7.1 → 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.1",
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) {
@@ -199,18 +263,25 @@ export const makeModel = <Rules extends z.ZodObject>(
199
263
  const sortableFields = new Set([...Object.keys(config.schema.shape), "id", "createdAt", "updatedAt"]);
200
264
  const hasExplicitSort = Boolean(sort && sortableFields.has(sort));
201
265
  // 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.
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).
203
271
  // `ORDER BY` only accepts a bare identifier here, not a function call — so relevance is
204
272
  // projected as an aliased field below (SELECT ... AS __relevance) and stripped back out
205
273
  // of each returned record afterwards, since it isn't part of the model's schema.
206
- const useRelevance = !hasExplicitSort && Boolean(search && config.searchFields.length);
274
+ const useRelevance = !hasExplicitSort && Boolean(search && searchPlan.some((e) => e.kind === "flat"));
207
275
  const orderBy = hasExplicitSort
208
276
  ? `ORDER BY ${sort} ${order === "desc" ? "DESC" : "ASC"}`
209
277
  : useRelevance
210
278
  ? "ORDER BY __relevance DESC"
211
279
  : "";
212
280
  const relevanceSelect = useRelevance
213
- ? `, (${config.searchFields.map((_, i) => `search::score(${i + 1})`).join(" + ")}) AS __relevance`
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`
214
285
  : "";
215
286
 
216
287
  // Validate populate against known relation fields — prevents FETCH injection
@@ -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,7 +1,9 @@
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://"];
@@ -24,7 +26,7 @@ const ensureTables = async (db: Surreal) => {
24
26
  const { default: userModel } = await import("../models/user");
25
27
  const { default: fileModel } = await import("../models/file");
26
28
 
27
- const models: { prefix: string; searchFields?: string[] }[] = [userModel, fileModel];
29
+ const models: { prefix: string; searchFields?: string[]; validation?: z.ZodObject }[] = [userModel, fileModel];
28
30
 
29
31
  for (const factory of getConfig().models) {
30
32
  const { default: model } = await factory();
@@ -40,18 +42,36 @@ const ensureTables = async (db: Surreal) => {
40
42
 
41
43
  await db.query(`DEFINE ANALYZER IF NOT EXISTS ${SEARCH_ANALYZER} TOKENIZERS blank FILTERS lowercase,ngram(2,10);`);
42
44
 
43
- const definedIndexes = new Set<string>(); // dedupe prefix+field in case two factories share a prefix
45
+ const defineSearchIndex = async (targetPrefix: string, field: string) => {
46
+ const key = `${targetPrefix}.${field}`;
47
+ if (definedIndexes.has(key)) return;
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_${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
44
58
  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
- );
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
+ }
55
75
  }
56
76
  }
57
77
  };