@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 +5 -0
- package/package.json +1 -1
- package/src/core/model/index.ts +99 -15
- package/src/modules/file.ts +1 -1
- package/src/modules/surreal.ts +38 -14
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.
|
|
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"],
|
package/src/core/model/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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 [
|
|
220
|
-
`SELECT
|
|
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
|
|
312
|
+
data,
|
|
229
313
|
meta: {
|
|
230
314
|
total,
|
|
231
315
|
page,
|
package/src/modules/file.ts
CHANGED
|
@@ -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 {
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
7
|
import { RecordId } from "surrealdb";
|
|
8
8
|
import z from "zod";
|
|
9
9
|
import auth from "./auth";
|
package/src/modules/surreal.ts
CHANGED
|
@@ -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()) —
|
|
12
|
-
// tokenizer splits on
|
|
13
|
-
//
|
|
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
|
|
43
|
+
await db.query(`DEFINE ANALYZER IF NOT EXISTS ${SEARCH_ANALYZER} TOKENIZERS blank FILTERS lowercase,ngram(2,10);`);
|
|
40
44
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
};
|