@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 +1 -1
- package/src/core/model/index.ts +34 -6
- package/src/modules/api.ts +30 -3
- package/src/modules/surreal.ts +31 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@njinlabs/njin",
|
|
3
|
-
"version": "0.
|
|
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"],
|
package/src/core/model/index.ts
CHANGED
|
@@ -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.
|
|
164
|
+
params.search = search.trim();
|
|
158
165
|
whereParts.push(
|
|
159
|
-
`(${config.searchFields.map((f) =>
|
|
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
|
|
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 [
|
|
206
|
-
`SELECT
|
|
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
|
|
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,
|
package/src/modules/api.ts
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
|
package/src/modules/surreal.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(() => {
|