@njinlabs/njin 0.6.1 → 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 +1 -1
- package/src/core/model/index.ts +18 -3
- package/src/modules/api.ts +30 -3
- package/src/modules/surreal.ts +27 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@njinlabs/njin",
|
|
3
|
-
"version": "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"],
|
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,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
|
-
|
|
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,
|
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,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
|
|
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
|
-
|
|
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(() => {
|