@njinlabs/njin 0.6.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@njinlabs/njin",
3
- "version": "0.6.0",
3
+ "version": "0.7.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"],
@@ -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,14 @@ 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
+ // 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
+ : "";
194
208
 
195
209
  // Validate populate against known relation fields — prevents FETCH injection
196
210
  const fetchFields =
@@ -262,6 +276,7 @@ export const makeModel = <Rules extends z.ZodObject>(
262
276
  create,
263
277
  destroy,
264
278
  read,
279
+ searchFields: config.searchFields,
265
280
  show,
266
281
  table,
267
282
  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
 
@@ -85,7 +85,17 @@ const img = makeModule(() => {
85
85
  let imageData: ArrayBuffer;
86
86
 
87
87
  if (url.startsWith("/")) {
88
- const resp = await fetch(`http://localhost:${getConfig().port}${url}`);
88
+ // A real network fetch to localhost:<port> only works when app.listen() actually bound
89
+ // that port — under njin-supervisor's worker mode (NJIN_WORKER=1, see core/worker.ts)
90
+ // nothing does, requests arrive via postMessage and are handled in-process instead, so
91
+ // that socket was never listening and this always failed with a connection error.
92
+ // Calling the same in-process Elysia instance's handle() works in both modes, and skips
93
+ // an unnecessary self-round-trip even outside worker mode. The origin is taken from the
94
+ // *incoming* request rather than hardcoded — routing itself only cares about
95
+ // pathname+query, but a handler further down that inspects request.url (absolute-URL
96
+ // generation, host-based logic, ...) should see the same scheme/host this request
97
+ // actually arrived on, not a fake one that may not match across environments.
98
+ const resp = await elysia().handle(new Request(`${new URL(request.url).origin}${url}`));
89
99
  if (!resp.ok) return new Response("Image not found", { status: 404 });
90
100
  imageData = await resp.arrayBuffer();
91
101
  } else {
@@ -8,6 +8,13 @@ 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
14
+ // the `@N@` match operator can find a term anywhere inside a field (not just a whole-field
15
+ // match) and still tolerate minor typos, similar to trigram search.
16
+ const SEARCH_ANALYZER = "njin_search";
17
+
11
18
  // SurrealDB's `GROUP ALL` aggregate (used for count queries) throws NotFoundError
12
19
  // on a table that has never had a row created, unlike plain SELECT. Defining every
13
20
  // table up front avoids that first-run failure (e.g. /api/setup/status before any user exists).
@@ -15,16 +22,34 @@ const ensureTables = async (db: Surreal) => {
15
22
  const { default: userModel } = await import("../models/user");
16
23
  const { default: fileModel } = await import("../models/file");
17
24
 
18
- const prefixes = new Set<string>([userModel.prefix, fileModel.prefix, "vars"]);
25
+ const models: { prefix: string; searchFields?: string[] }[] = [userModel, fileModel];
19
26
 
20
27
  for (const factory of getConfig().models) {
21
28
  const { default: model } = await factory();
22
- prefixes.add(model.prefix);
29
+ models.push(model);
23
30
  }
24
31
 
32
+ const prefixes = new Set<string>(models.map((model) => model.prefix));
33
+ prefixes.add("vars");
34
+
25
35
  for (const prefix of prefixes) {
26
36
  await db.query(`DEFINE TABLE IF NOT EXISTS ${prefix} SCHEMALESS;`);
27
37
  }
38
+
39
+ await db.query(`DEFINE ANALYZER IF NOT EXISTS ${SEARCH_ANALYZER} TOKENIZERS class FILTERS lowercase,ngram(2,10);`);
40
+
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);
47
+
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
+ );
51
+ }
52
+ }
28
53
  };
29
54
 
30
55
  const surreal = makeModule(() => {