@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 +5 -0
- package/package.json +1 -1
- package/src/core/model/index.ts +79 -8
- package/src/modules/file.ts +1 -1
- package/src/modules/surreal.ts +32 -12
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) {
|
|
@@ -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
|
|
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 &&
|
|
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
|
-
? `, (${
|
|
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
|
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,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
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
};
|