@njinlabs/njin 0.1.2 → 0.2.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.
@@ -0,0 +1,143 @@
1
+ export type HookEvent =
2
+ | "beforeCreate"
3
+ | "afterCreate"
4
+ | "beforeUpdate"
5
+ | "afterUpdate"
6
+ | "beforeDestroy"
7
+ | "afterDestroy"
8
+ | "beforeVarsUpdate"
9
+ | "afterVarsUpdate";
10
+
11
+ // Minimal structural shape needed from a model instance to infer hook types —
12
+ // deliberately not importing makeModel's return type here to avoid a circular
13
+ // import with ./index.ts (which imports this file).
14
+ type AnyModel = {
15
+ prefix: string;
16
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
17
+ create: (...args: any[]) => Promise<any>;
18
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
+ update: (...args: any[]) => Promise<any>;
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ destroy: (...args: any[]) => Promise<any>;
22
+ };
23
+
24
+ type RecordOf<M extends AnyModel> = Awaited<ReturnType<M["create"]>>;
25
+ type CreateDataOf<M extends AnyModel> = Parameters<M["create"]>[0];
26
+ type UpdateDataOf<M extends AnyModel> = Parameters<M["update"]>[1];
27
+
28
+ // Minimal structural shape needed from a vars group (makeVars, ../vars/index.ts) —
29
+ // singleton settings object, no create/destroy, and update(data) takes a single
30
+ // argument (no id, unlike a model's update(id, data): there's only ever one record
31
+ // per vars group, keyed by its own prefix).
32
+ type AnyVars = {
33
+ prefix: string;
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ update: (...args: any[]) => Promise<any>;
36
+ };
37
+
38
+ type VarsRecordOf<V extends AnyVars> = Awaited<ReturnType<V["update"]>>;
39
+ type VarsUpdateDataOf<V extends AnyVars> = Parameters<V["update"]>[0];
40
+
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ type HookHandler = (...args: any[]) => unknown;
43
+
44
+ const registry = new Map<string, Map<HookEvent, HookHandler[]>>();
45
+
46
+ const register = (event: HookEvent, prefix: string, fn: HookHandler) => {
47
+ let byEvent = registry.get(prefix);
48
+ if (!byEvent) {
49
+ byEvent = new Map();
50
+ registry.set(prefix, byEvent);
51
+ }
52
+
53
+ const handlers = byEvent.get(event);
54
+ if (handlers) {
55
+ handlers.push(fn);
56
+ } else {
57
+ byEvent.set(event, [fn]);
58
+ }
59
+ };
60
+
61
+ export const beforeCreate = <M extends AnyModel>(
62
+ model: M,
63
+ fn: (
64
+ data: Partial<CreateDataOf<M>>,
65
+ ) => void | Partial<CreateDataOf<M>> | Promise<void | Partial<CreateDataOf<M>>>,
66
+ ): void => register("beforeCreate", model.prefix, fn);
67
+
68
+ export const afterCreate = <M extends AnyModel>(model: M, fn: (record: RecordOf<M>) => void | Promise<void>): void =>
69
+ register("afterCreate", model.prefix, fn);
70
+
71
+ export const beforeUpdate = <M extends AnyModel>(
72
+ model: M,
73
+ fn: (
74
+ data: Partial<UpdateDataOf<M>>,
75
+ context: { id: string },
76
+ ) => void | Partial<UpdateDataOf<M>> | Promise<void | Partial<UpdateDataOf<M>>>,
77
+ ): void => register("beforeUpdate", model.prefix, fn);
78
+
79
+ export const afterUpdate = <M extends AnyModel>(model: M, fn: (record: RecordOf<M>) => void | Promise<void>): void =>
80
+ register("afterUpdate", model.prefix, fn);
81
+
82
+ export const beforeDestroy = <M extends AnyModel>(model: M, fn: (id: string) => void | Promise<void>): void =>
83
+ register("beforeDestroy", model.prefix, fn);
84
+
85
+ export const afterDestroy = <M extends AnyModel>(model: M, fn: (record: RecordOf<M>) => void | Promise<void>): void =>
86
+ register("afterDestroy", model.prefix, fn);
87
+
88
+ export const beforeVarsUpdate = <V extends AnyVars>(
89
+ vars: V,
90
+ fn: (
91
+ data: Partial<VarsUpdateDataOf<V>>,
92
+ ) => void | Partial<VarsUpdateDataOf<V>> | Promise<void | Partial<VarsUpdateDataOf<V>>>,
93
+ ): void => register("beforeVarsUpdate", vars.prefix, fn);
94
+
95
+ export const afterVarsUpdate = <V extends AnyVars>(
96
+ vars: V,
97
+ fn: (record: VarsRecordOf<V>) => void | Promise<void>,
98
+ ): void => register("afterVarsUpdate", vars.prefix, fn);
99
+
100
+ // Internal dispatch used by makeModel — runs handlers sequentially (await each),
101
+ // merges before*-handler return values into data, and lets errors propagate so a
102
+ // throwing hook aborts the operation (same contract as UniqueConstraintError).
103
+ export const runBeforeHooks = async <T extends Record<string, unknown>>(
104
+ event: "beforeCreate" | "beforeUpdate" | "beforeVarsUpdate",
105
+ prefix: string,
106
+ data: Partial<T>,
107
+ context: { id?: string },
108
+ ): Promise<Partial<T>> => {
109
+ const handlers = registry.get(prefix)?.get(event) as
110
+ | ((data: Partial<T>, context: { id?: string }) => void | Partial<T> | Promise<void | Partial<T>>)[]
111
+ | undefined;
112
+ if (!handlers?.length) return data;
113
+
114
+ let current = data;
115
+ for (const handler of handlers) {
116
+ const result = await handler(current, context);
117
+ if (result) current = { ...current, ...result };
118
+ }
119
+
120
+ return current;
121
+ };
122
+
123
+ export const runAfterHooks = async <T>(
124
+ event: "afterCreate" | "afterUpdate" | "afterDestroy" | "afterVarsUpdate",
125
+ prefix: string,
126
+ record: T,
127
+ ): Promise<void> => {
128
+ const handlers = registry.get(prefix)?.get(event) as ((record: T) => void | Promise<void>)[] | undefined;
129
+ if (!handlers?.length) return;
130
+
131
+ for (const handler of handlers) {
132
+ await handler(record);
133
+ }
134
+ };
135
+
136
+ export const runBeforeDestroyHooks = async (prefix: string, id: string): Promise<void> => {
137
+ const handlers = registry.get(prefix)?.get("beforeDestroy") as ((id: string) => void | Promise<void>)[] | undefined;
138
+ if (!handlers?.length) return;
139
+
140
+ for (const handler of handlers) {
141
+ await handler(id);
142
+ }
143
+ };
@@ -2,6 +2,7 @@ import surreal from "../../modules/surreal";
2
2
  import moment from "moment";
3
3
  import { RecordId, Table, type Values } from "surrealdb";
4
4
  import { z } from "zod";
5
+ import { runAfterHooks, runBeforeDestroyHooks, runBeforeHooks } from "./hooks";
5
6
 
6
7
  export type FormMeta = {
7
8
  label: string;
@@ -93,6 +94,13 @@ export const makeModel = <Rules extends z.ZodObject>(
93
94
 
94
95
  const uniqueFieldSet = new Set(uniqueFields);
95
96
 
97
+ // A real Set, not `key in config.schema.shape` — `in` also matches inherited
98
+ // Object.prototype members (e.g. "constructor", "toString"), and JSON.parse can
99
+ // produce those as literal own-properties on a request body/query object, letting
100
+ // them slip past a schema-shape membership check and get interpolated as a field
101
+ // name into the raw query string built below.
102
+ const filterableFieldSet = new Set(Object.keys(config.schema.shape));
103
+
96
104
  // field must be a known unique field — prevents arbitrary field injection
97
105
  const isDuplicate = async (field: string, value: unknown, excludeId?: string) => {
98
106
  if (!uniqueFieldSet.has(field)) return false;
@@ -118,13 +126,19 @@ export const makeModel = <Rules extends z.ZodObject>(
118
126
  };
119
127
 
120
128
  const create = async (data: Values<Data>) => {
121
- await assertUnique(data);
129
+ const merged = (await runBeforeHooks("beforeCreate", prefix, data as Record<string, unknown>, {})) as Values<Data>;
130
+
131
+ await assertUnique(merged);
122
132
 
123
- return surreal()
133
+ const record = (await surreal()
124
134
  .create<Data>(table)
125
- .content({ ...data, createdAt: moment().toISOString(), updatedAt: moment().toISOString() })
135
+ .content({ ...merged, createdAt: moment().toISOString(), updatedAt: moment().toISOString() })
126
136
  .output("after")
127
- .then(([data]) => data) as Promise<Returning>;
137
+ .then(([data]) => data)) as Returning;
138
+
139
+ await runAfterHooks("afterCreate", prefix, record);
140
+
141
+ return record;
128
142
  };
129
143
 
130
144
  const read = async ({
@@ -149,7 +163,7 @@ export const makeModel = <Rules extends z.ZodObject>(
149
163
  if (filters) {
150
164
  for (const [key, value] of Object.entries(filters)) {
151
165
  // key must exist in schema — prevents arbitrary field injection
152
- if (!(key in config.schema.shape)) continue;
166
+ if (!filterableFieldSet.has(key)) continue;
153
167
 
154
168
  if (typeof value === "string") {
155
169
  // Shorthand: filters[field]=value → equality
@@ -214,18 +228,32 @@ export const makeModel = <Rules extends z.ZodObject>(
214
228
  };
215
229
 
216
230
  const update = async (id: string, data: Values<Partial<Data>>) => {
217
- await assertUnique(data, id);
231
+ const merged = (await runBeforeHooks("beforeUpdate", prefix, data as Record<string, unknown>, {
232
+ id,
233
+ })) as Values<Partial<Data>>;
234
+
235
+ await assertUnique(merged, id);
218
236
 
219
- return surreal()
237
+ const record = (await (surreal()
220
238
  .update<Data>(new RecordId(table, id))
221
239
  .merge({
222
- ...data,
240
+ ...merged,
223
241
  updatedAt: moment().toISOString(),
224
- }) as unknown as Promise<Returning>;
242
+ }) as unknown as Promise<Returning>)) as Returning;
243
+
244
+ await runAfterHooks("afterUpdate", prefix, record);
245
+
246
+ return record;
225
247
  };
226
248
 
227
- const destroy = (id: string) => {
228
- return surreal().delete<Data>(new RecordId(table, id)) as unknown as Promise<Returning>;
249
+ const destroy = async (id: string) => {
250
+ await runBeforeDestroyHooks(prefix, id);
251
+
252
+ const record = (await (surreal().delete<Data>(new RecordId(table, id)) as unknown as Promise<Returning>)) as Returning;
253
+
254
+ await runAfterHooks("afterDestroy", prefix, record);
255
+
256
+ return record;
229
257
  };
230
258
 
231
259
  return {
@@ -242,3 +270,8 @@ export const makeModel = <Rules extends z.ZodObject>(
242
270
  };
243
271
 
244
272
  export * from "./data_type";
273
+ export * from "./hooks";
274
+ export * from "../event";
275
+ export * from "../plugin";
276
+ export * from "../route";
277
+ export * from "../vars";
@@ -1,9 +1,23 @@
1
- import { join, normalize } from "node:path";
1
+ import { basename, join, normalize, sep } from "node:path";
2
2
 
3
3
  // Resolves `requestedPath` against `baseDir` and rejects anything that escapes it
4
4
  // (e.g. "../../etc/passwd") — shared by every module that serves files straight
5
5
  // off disk based on a request path (uploads, admin panel SPA, etc).
6
6
  export const resolveSafePath = (baseDir: string, requestedPath: string): string | null => {
7
+ // A bare `startsWith` prefix check would let "/app/uploads-evil" pass for a
8
+ // baseDir of "/app/uploads" — anchor the comparison to a directory boundary
9
+ // instead so only baseDir itself (or a path inside it) matches.
10
+ const normalizedBase = normalize(baseDir);
11
+ const boundary = normalizedBase.endsWith(sep) ? normalizedBase : normalizedBase + sep;
7
12
  const resolved = normalize(join(baseDir, requestedPath));
8
- return resolved.startsWith(baseDir) ? resolved : null;
13
+ return resolved === normalizedBase || resolved.startsWith(boundary) ? resolved : null;
14
+ };
15
+
16
+ // Strips any directory component from a client-supplied filename (e.g. a multipart
17
+ // upload's `file.name`), leaving only the final path segment. Without this, a name
18
+ // like "../../../config.ts" would let an upload adapter write (or an S3 key target)
19
+ // outside the directory/prefix it's meant to be confined to.
20
+ export const sanitizeFileName = (name: string): string => {
21
+ const base = basename(name);
22
+ return base === "" || base === "." || base === ".." ? "file" : base;
9
23
  };
@@ -0,0 +1,13 @@
1
+ import type { EventFactory, HookFactory, ModelFactory, RouteFactory, VarsFactory } from "./config";
2
+
3
+ export type Plugin = {
4
+ models?: ModelFactory[];
5
+ vars?: VarsFactory[];
6
+ hooks?: HookFactory[];
7
+ events?: EventFactory[];
8
+ routes?: RouteFactory[];
9
+ init?: () => Promise<void> | void;
10
+ };
11
+
12
+ // Identity function — DX/typing only, same purpose as defineConfig/route().
13
+ export const definePlugin = (plugin: Plugin): Plugin => plugin;
@@ -0,0 +1,6 @@
1
+ import { Elysia } from "elysia";
2
+
3
+ // Thin factory over Elysia itself — purely for DX (autocomplete/type-checking),
4
+ // same purpose as defineConfig. Custom route files get the full Elysia API
5
+ // (schema validation, guards, groups, macros, ...) with no wrapping layer.
6
+ export const route = (config?: ConstructorParameters<typeof Elysia>[0]) => new Elysia(config);
@@ -0,0 +1,64 @@
1
+ import surreal from "../../modules/surreal";
2
+ import moment from "moment";
3
+ import { RecordId, Table, type Values } from "surrealdb";
4
+ import { z } from "zod";
5
+ import { runAfterHooks, runBeforeHooks } from "../model/hooks";
6
+
7
+ // One shared physical table for every VARS group — rows are keyed by each
8
+ // group's own prefix as the record id (vars:general, vars:seo, ...), not one
9
+ // table per group. See surreal.ts's ensureTables() for the DEFINE TABLE call.
10
+ const table = new Table("vars");
11
+
12
+ export const makeVars = <Rules extends z.ZodObject>(
13
+ prefix: string,
14
+ config: {
15
+ schema: Rules;
16
+ name: string;
17
+ },
18
+ ) => {
19
+ type Data = z.infer<Rules>;
20
+ type Returning = Data & { updatedAt: string | null };
21
+
22
+ const recordId = new RecordId(table, prefix);
23
+
24
+ // schema.parse() fills in every field's .default() for anything missing —
25
+ // including the row never having been saved at all — so get() always
26
+ // returns a complete, valid object. Every VARS field is expected to
27
+ // declare a .default(...) for this reason.
28
+ const get = async (): Promise<Returning> => {
29
+ const record = await surreal().select<Data & { updatedAt?: string }>(recordId);
30
+ const { updatedAt, ...rest } = (record ?? {}) as Record<string, unknown> & { updatedAt?: string };
31
+ return { ...(config.schema.parse(rest) as Data), updatedAt: updatedAt ?? null };
32
+ };
33
+
34
+ // Upsert, not update — the row may not exist yet on first save.
35
+ const update = async (data: Values<Partial<Data>>): Promise<Returning> => {
36
+ const merged = (await runBeforeHooks(
37
+ "beforeVarsUpdate",
38
+ prefix,
39
+ data as Record<string, unknown>,
40
+ {},
41
+ )) as Values<Partial<Data>>;
42
+
43
+ const record = (await surreal()
44
+ .upsert<Data & { updatedAt: string }>(recordId)
45
+ .merge({ ...merged, updatedAt: moment().toISOString() } as Values<Data & { updatedAt: string }>)
46
+ .output("after")) as (Data & { updatedAt: string }) | undefined;
47
+
48
+ const { updatedAt, ...rest } = (record ?? {}) as Record<string, unknown> & { updatedAt?: string };
49
+ const result = { ...(config.schema.parse(rest) as Data), updatedAt: updatedAt ?? null };
50
+
51
+ await runAfterHooks("afterVarsUpdate", prefix, result);
52
+
53
+ return result;
54
+ };
55
+
56
+ return {
57
+ name: config.name,
58
+ prefix,
59
+ table,
60
+ validation: config.schema,
61
+ get,
62
+ update,
63
+ };
64
+ };
@@ -5,13 +5,49 @@ import z from "zod";
5
5
  import auth from "./auth";
6
6
  import elysia from "./elysia";
7
7
 
8
+ // Shared by both models and vars groups on the /api/schema endpoint — strips
9
+ // non-JSON-representable renderAs types (relation/multi_relation/file) into
10
+ // plain JSON-schema shapes the admin panel can render a form from.
11
+ const toAdminSchema = (schema: z.ZodObject) =>
12
+ schema.toJSONSchema({
13
+ unrepresentable: "any",
14
+ override: (ctx) => {
15
+ if (ctx.jsonSchema.renderAs === "relation") {
16
+ ctx.jsonSchema.type = "object";
17
+ ctx.jsonSchema.properties = {
18
+ id: {
19
+ type: "string",
20
+ },
21
+ };
22
+ ctx.jsonSchema.required = ["id"];
23
+ ctx.jsonSchema.additionalProperties = {};
24
+ } else if (ctx.jsonSchema.renderAs === "multi_relation") {
25
+ (ctx.jsonSchema.items! as any).type = "string";
26
+ } else if (ctx.jsonSchema.renderAs === "file") {
27
+ ctx.jsonSchema.type = "string";
28
+ }
29
+ },
30
+ });
31
+
8
32
  const api = makeModule(() => {
9
33
  const fn = () => {};
10
34
 
11
35
  fn.init = async () => {
12
36
  const models = getConfig().models;
37
+ const hooks = getConfig().hooks;
38
+ const events = getConfig().events;
39
+ const routes = getConfig().routes;
40
+ const varsGroups = getConfig().vars;
13
41
  const authPlugin = await auth();
14
42
 
43
+ // Trigger each hook file's import side effect (afterCreate()/etc. registration)
44
+ // before any route goes live, so hooks are always registered before the first request.
45
+ for (const promise of hooks) await promise();
46
+
47
+ // Same reasoning as hooks above, for the event bus (src/core/event.ts): drain
48
+ // every listener file's .listen() registration before any route goes live.
49
+ for (const promise of events) await promise();
50
+
15
51
  const controller = new Elysia({ prefix: "/api/schema" }).use(authPlugin.plugin).get(
16
52
  "/",
17
53
  async () => {
@@ -23,31 +59,27 @@ const api = makeModule(() => {
23
59
  return {
24
60
  name: data.name,
25
61
  prefix: data.prefix,
26
- schema: data.validation.toJSONSchema({
27
- unrepresentable: "any",
28
- override: (ctx) => {
29
- if (ctx.jsonSchema.renderAs === "relation") {
30
- ctx.jsonSchema.type = "object";
31
- ctx.jsonSchema.properties = {
32
- id: {
33
- type: "string",
34
- },
35
- };
36
- ctx.jsonSchema.required = ["id"];
37
- ctx.jsonSchema.additionalProperties = {};
38
- } else if (ctx.jsonSchema.renderAs === "multi_relation") {
39
- (ctx.jsonSchema.items! as any).type = "string";
40
- } else if (ctx.jsonSchema.renderAs === "file") {
41
- ctx.jsonSchema.type = "string";
42
- }
43
- },
44
- }),
62
+ schema: toAdminSchema(data.validation),
63
+ };
64
+ })(),
65
+ ),
66
+ );
67
+
68
+ const allVars = await Promise.all(
69
+ varsGroups.map((varsGroup) =>
70
+ (async () => {
71
+ const { default: group } = await varsGroup();
72
+
73
+ return {
74
+ name: group.name,
75
+ prefix: group.prefix,
76
+ schema: toAdminSchema(group.validation),
45
77
  };
46
78
  })(),
47
79
  ),
48
80
  );
49
81
 
50
- return { data: allModels };
82
+ return { data: allModels, vars: allVars };
51
83
  },
52
84
  {
53
85
  auth: true,
@@ -151,6 +183,11 @@ const api = makeModule(() => {
151
183
  elysia().use(controller);
152
184
  }
153
185
 
186
+ for (const promise of routes) {
187
+ const { default: plugin } = await promise();
188
+ elysia().use(plugin);
189
+ }
190
+
154
191
  return {};
155
192
  };
156
193