@njinlabs/njin 0.8.0 → 0.10.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.8.0",
3
+ "version": "0.10.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"],
@@ -13,21 +13,37 @@ import logger from "./logger";
13
13
  // without any per-route setup.
14
14
  const BRACKET_KEY_RE = /^([^[\]]+)\[([^\]]+)\](?:\[([^\]]+)\])?$/;
15
15
 
16
- export const injectBracketQuery = ({ query, request }: { query: Record<string, unknown>; request: Request }) => {
16
+ export const injectBracketQuery = ({
17
+ query,
18
+ request,
19
+ }: {
20
+ query: Record<string, unknown>;
21
+ request: Request;
22
+ }) => {
17
23
  const params = new URL(request.url).searchParams;
18
24
 
19
25
  for (const [key, value] of params) {
20
26
  const match = BRACKET_KEY_RE.exec(key);
21
27
  if (!match) continue;
22
28
 
23
- const [, root, sub1, sub2] = match as unknown as [string, string, string, string | undefined];
29
+ const [, root, sub1, sub2] = match as unknown as [
30
+ string,
31
+ string,
32
+ string,
33
+ string | undefined,
34
+ ];
24
35
  const existingRoot = query[root];
25
- const rootObj = typeof existingRoot === "object" && existingRoot !== null ? (existingRoot as Record<string, unknown>) : {};
36
+ const rootObj =
37
+ typeof existingRoot === "object" && existingRoot !== null
38
+ ? (existingRoot as Record<string, unknown>)
39
+ : {};
26
40
 
27
41
  if (sub2) {
28
42
  const existingSub = rootObj[sub1];
29
43
  rootObj[sub1] = {
30
- ...(typeof existingSub === "object" && existingSub !== null ? (existingSub as Record<string, unknown>) : {}),
44
+ ...(typeof existingSub === "object" && existingSub !== null
45
+ ? (existingSub as Record<string, unknown>)
46
+ : {}),
31
47
  [sub2]: value,
32
48
  };
33
49
  } else {
@@ -47,29 +63,35 @@ const elysia = makeModule(() => {
47
63
 
48
64
  fn.init = () => {
49
65
  app = new Elysia()
50
- .use(cors())
66
+ .use(
67
+ cors({
68
+ origin: () => true,
69
+ }),
70
+ )
51
71
  .onTransform({ as: "global" }, injectBracketQuery)
52
72
  .onError(({ error }) => {
53
- if (error instanceof ValidationError) {
54
- return status(422, {
55
- message: "Validation error",
56
- errors: (error.detail("") as { errors: { value: {}; summary: string }[] }).errors.map(({ value, summary, ...err }) => err),
57
- });
58
- }
73
+ if (error instanceof ValidationError) {
74
+ return status(422, {
75
+ message: "Validation error",
76
+ errors: (
77
+ error.detail("") as { errors: { value: {}; summary: string }[] }
78
+ ).errors.map(({ value, summary, ...err }) => err),
79
+ });
80
+ }
59
81
 
60
- if (error instanceof UniqueConstraintError) {
61
- return status(409, {
62
- message: error.message,
63
- field: error.field,
64
- });
65
- }
82
+ if (error instanceof UniqueConstraintError) {
83
+ return status(409, {
84
+ message: error.message,
85
+ field: error.field,
86
+ });
87
+ }
66
88
 
67
- logger().error(error);
89
+ logger().error(error);
68
90
 
69
- return status(500, {
70
- message: "Internal Server Error",
91
+ return status(500, {
92
+ message: "Internal Server Error",
93
+ });
71
94
  });
72
- });
73
95
 
74
96
  return {
75
97
  // No startup log here — the CLI prints one consolidated banner after every
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { getConfig } from "../core/config";
2
3
  import { resolveSearchPlan } from "../core/model";
3
4
  import { makeModule } from "../core/module";
@@ -19,6 +20,13 @@ export const isRemotePath = (path: string) => REMOTE_SCHEMES.some((scheme) => pa
19
20
  // match) and still tolerate minor typos, similar to trigram search.
20
21
  const SEARCH_ANALYZER = "njin_search";
21
22
 
23
+ // Records the hash of the last schema this DB was migrated to, so a worker booting against
24
+ // an already-migrated DB (idle-evict/crash respawn — every DEFINE below is idempotent but
25
+ // still a full network round trip per statement) can skip straight past ensureTables(). Keyed
26
+ // off the DB itself rather than the process/build: a worker pointed at a fresh or different DB
27
+ // (e.g. a per-client env change) finds no matching record here and still runs the DEFINEs.
28
+ const SCHEMA_META_TABLE = "njin_schema_meta";
29
+
22
30
  // SurrealDB's `GROUP ALL` aggregate (used for count queries) throws NotFoundError
23
31
  // on a table that has never had a row created, unlike plain SELECT. Defining every
24
32
  // table up front avoids that first-run failure (e.g. /api/setup/status before any user exists).
@@ -36,25 +44,11 @@ const ensureTables = async (db: Surreal) => {
36
44
  const prefixes = new Set<string>(models.map((model) => model.prefix));
37
45
  prefixes.add("vars");
38
46
 
39
- for (const prefix of prefixes) {
40
- await db.query(`DEFINE TABLE IF NOT EXISTS ${prefix} SCHEMALESS;`);
41
- }
42
-
43
- await db.query(`DEFINE ANALYZER IF NOT EXISTS ${SEARCH_ANALYZER} TOKENIZERS blank FILTERS lowercase,ngram(2,10);`);
44
-
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
47
+ // Resolve every search index's target table+field up front (also dedupes prefix+field in
48
+ // case two factories share a prefix, or a nested reference targets an already-indexed
49
+ // field) — needed both to compute the schema hash below and to drive the DEFINE INDEX loop
50
+ // further down.
51
+ const searchIndexes = new Map<string, { prefix: string; field: string }>();
58
52
  for (const model of models) {
59
53
  // A nested searchFields entry (e.g. "author.name") needs its index defined on the
60
54
  // *target* table/field instead — the local relation field holds a record link, not
@@ -67,13 +61,43 @@ const ensureTables = async (db: Surreal) => {
67
61
  : (model.searchFields ?? []).map((field) => ({ kind: "flat" as const, field }));
68
62
 
69
63
  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
- }
64
+ const target =
65
+ entry.kind === "flat"
66
+ ? { prefix: model.prefix, field: entry.field }
67
+ : { prefix: entry.targetPrefix, field: entry.targetField };
68
+ searchIndexes.set(`${target.prefix}.${target.field}`, target);
75
69
  }
76
70
  }
71
+
72
+ const schemaHash = createHash("sha256")
73
+ .update(JSON.stringify({ prefixes: [...prefixes].sort(), searchIndexes: [...searchIndexes.keys()].sort() }))
74
+ .digest("hex");
75
+
76
+ // Must be DEFINE'd before the SELECT below can even run — unlike a table that exists but
77
+ // has no rows (see the GROUP ALL note above), a table SurrealDB has never seen DEFINE'd at
78
+ // all makes ANY query against a specific record id in it throw NotFoundError, plain SELECT
79
+ // included. One extra round trip on every boot (skip path too), still far cheaper than the
80
+ // N DEFINEs it's gating.
81
+ await db.query(`DEFINE TABLE IF NOT EXISTS ${SCHEMA_META_TABLE} SCHEMALESS;`);
82
+
83
+ const [rows] = await db.query<[{ hash: string }[]]>(`SELECT hash FROM ${SCHEMA_META_TABLE}:current;`);
84
+ if (rows?.[0]?.hash === schemaHash) return;
85
+
86
+ for (const prefix of prefixes) {
87
+ await db.query(`DEFINE TABLE IF NOT EXISTS ${prefix} SCHEMALESS;`);
88
+ }
89
+
90
+ await db.query(`DEFINE ANALYZER IF NOT EXISTS ${SEARCH_ANALYZER} TOKENIZERS blank FILTERS lowercase,ngram(2,10);`);
91
+
92
+ for (const { prefix: targetPrefix, field } of searchIndexes.values()) {
93
+ // FULLTEXT, not SEARCH — this SurrealDB version renamed the index-type keyword;
94
+ // SEARCH ANALYZER ... is a parse error here even though older docs/examples use it.
95
+ await db.query(
96
+ `DEFINE INDEX IF NOT EXISTS idx_search_${targetPrefix}_${field} ON TABLE ${targetPrefix} FIELDS ${field} FULLTEXT ANALYZER ${SEARCH_ANALYZER} BM25 HIGHLIGHTS;`,
97
+ );
98
+ }
99
+
100
+ await db.query(`UPSERT ${SCHEMA_META_TABLE}:current SET hash = '${schemaHash}';`);
77
101
  };
78
102
 
79
103
  const surreal = makeModule(() => {