@rebasepro/server-core 0.3.0 → 0.5.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.
Files changed (50) hide show
  1. package/README.md +62 -25
  2. package/dist/common/src/collections/default-collections.d.ts +5 -8
  3. package/dist/common/src/data/query_builder.d.ts +6 -2
  4. package/dist/common/src/util/permissions.d.ts +14 -6
  5. package/dist/index.es.js +393 -315
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/index.umd.js +393 -315
  8. package/dist/index.umd.js.map +1 -1
  9. package/dist/server-core/src/api/errors.d.ts +15 -0
  10. package/dist/server-core/src/api/rest/query-parser.d.ts +2 -0
  11. package/dist/server-core/src/api/types.d.ts +2 -1
  12. package/dist/server-core/src/auth/jwt.d.ts +10 -0
  13. package/dist/server-core/src/email/types.d.ts +1 -0
  14. package/dist/server-core/src/init.d.ts +31 -1
  15. package/dist/types/src/controllers/auth.d.ts +2 -2
  16. package/dist/types/src/controllers/client.d.ts +25 -40
  17. package/dist/types/src/controllers/data.d.ts +21 -3
  18. package/dist/types/src/controllers/data_driver.d.ts +5 -0
  19. package/dist/types/src/controllers/email.d.ts +2 -0
  20. package/dist/types/src/types/auth_adapter.d.ts +3 -56
  21. package/dist/types/src/types/backend.d.ts +38 -3
  22. package/dist/types/src/types/backend_hooks.d.ts +2 -17
  23. package/dist/types/src/types/collections.d.ts +30 -6
  24. package/dist/types/src/types/entity_views.d.ts +19 -28
  25. package/dist/types/src/types/properties.d.ts +9 -15
  26. package/dist/types/src/types/user_management_delegate.d.ts +16 -53
  27. package/dist/types/src/users/index.d.ts +0 -1
  28. package/dist/types/src/users/user.d.ts +0 -1
  29. package/package.json +5 -5
  30. package/src/api/errors.ts +20 -1
  31. package/src/api/openapi-generator.ts +1 -1
  32. package/src/api/rest/api-generator.ts +82 -24
  33. package/src/api/rest/query-parser.ts +184 -63
  34. package/src/api/server.ts +1 -1
  35. package/src/api/types.ts +2 -1
  36. package/src/auth/admin-routes.ts +2 -91
  37. package/src/auth/builtin-auth-adapter.ts +9 -70
  38. package/src/auth/custom-auth-adapter.ts +1 -1
  39. package/src/auth/jwt.ts +10 -0
  40. package/src/auth/routes.ts +5 -9
  41. package/src/email/smtp-email-service.ts +31 -0
  42. package/src/email/types.ts +1 -0
  43. package/src/init.ts +135 -31
  44. package/src/storage/image-transform.ts +2 -1
  45. package/test/admin-routes.test.ts +0 -169
  46. package/test/backend-hooks-admin.test.ts +0 -25
  47. package/test/custom-auth-adapter.test.ts +2 -10
  48. package/test/smtp-email-service.test.ts +169 -0
  49. package/dist/types/src/users/roles.d.ts +0 -14
  50. package/test.ts +0 -6
@@ -56,41 +56,32 @@ export type EntityCustomView<M extends Record<string, unknown> = Record<string,
56
56
  Builder?: ComponentRef<EntityCustomViewParams<M>>;
57
57
  position?: "start" | "end";
58
58
  };
59
- export interface EntityCustomViewParams<M extends Record<string, unknown> = Record<string, unknown>> {
60
- collection: EntityCollection<M>;
61
- entity?: Entity<M>;
62
- modifiedValues?: EntityValues<M>;
63
- formContext: FormContext<M>;
64
- parentCollectionSlugs?: string[];
65
- parentEntityIds?: string[];
66
- }
67
59
  /**
68
- * Configuration for customizing the read-only detail view of an entity.
69
- * Only used when `defaultEntityAction` is set to `"view"` on the collection.
60
+ * Configuration to replace the default entity form with a custom component.
61
+ * The Builder receives the same props as entity view tabs (entity, formContext, etc.)
62
+ * and has full control over the UI.
63
+ *
64
+ * The form tab still appears in the tab bar but renders your Builder
65
+ * instead of the auto-generated field form.
66
+ *
70
67
  * @group Models
71
68
  */
72
- export type EntityDetailViewConfig<M extends Record<string, unknown> = Record<string, unknown>> = {
73
- /**
74
- * Custom component rendered above the property display in the detail view.
75
- */
76
- Header?: ComponentRef<EntityDetailViewParams<M>>;
69
+ export type FormViewConfig<M extends Record<string, unknown> = Record<string, unknown>> = {
77
70
  /**
78
- * Custom component rendered below the property display in the detail view.
71
+ * Custom component that replaces the default form.
79
72
  */
80
- Footer?: ComponentRef<EntityDetailViewParams<M>>;
73
+ Builder: ComponentRef<EntityCustomViewParams<M>>;
81
74
  /**
82
- * Completely replace the default detail view with a custom component.
83
- * When set, Header and Footer are ignored.
75
+ * If true, the save/delete action bar is rendered alongside the custom view.
76
+ * Defaults to true.
84
77
  */
85
- Builder?: ComponentRef<EntityDetailViewParams<M>>;
78
+ includeActions?: boolean;
86
79
  };
87
- /**
88
- * Props passed to detail view customization components (Header, Footer, Builder).
89
- * @group Models
90
- */
91
- export interface EntityDetailViewParams<M extends Record<string, unknown> = Record<string, unknown>> {
80
+ export interface EntityCustomViewParams<M extends Record<string, unknown> = Record<string, unknown>> {
92
81
  collection: EntityCollection<M>;
93
- entity: Entity<M>;
94
- path: string;
95
- onEditClick: () => void;
82
+ entity?: Entity<M>;
83
+ modifiedValues?: EntityValues<M>;
84
+ formContext: FormContext<M>;
85
+ parentCollectionSlugs?: string[];
86
+ parentEntityIds?: string[];
96
87
  }
@@ -1,6 +1,6 @@
1
1
  import type { ComponentRef } from "./component_ref";
2
- import type { EntityReference, EntityRelation, EntityValues, GeoPoint, Entity, Vector } from "./entities";
3
- import type { Relation, JoinStep, OnAction } from "./relations";
2
+ import type { Entity, EntityReference, EntityRelation, EntityValues, GeoPoint, Vector } from "./entities";
3
+ import type { JoinStep, OnAction, Relation } from "./relations";
4
4
  import type { EntityCollection, FilterValues } from "./collections";
5
5
  import type { ColorKey, ColorScheme } from "./chips";
6
6
  import type { AuthController } from "../controllers/auth";
@@ -104,8 +104,8 @@ export interface BaseUIConfig<CustomProps = unknown> {
104
104
  disabled?: boolean | PropertyDisabledConfig;
105
105
  widthPercentage?: number;
106
106
  customProps?: CustomProps;
107
- Field?: ComponentRef;
108
- Preview?: ComponentRef;
107
+ Field?: ComponentRef<any>;
108
+ Preview?: ComponentRef<any>;
109
109
  }
110
110
  export interface BaseProperty<CustomProps = unknown> {
111
111
  ui?: BaseUIConfig<CustomProps>;
@@ -185,7 +185,7 @@ export interface StringProperty extends BaseProperty {
185
185
  * Optional database column type. If not set, it defaults to `varchar` or `uuid` depending on `isId` configuration.
186
186
  * Use `text` for strings with unbound length, `char` for fixed-length strings, or `varchar` for variable-length strings with a limit.
187
187
  */
188
- columnType?: "varchar" | "text" | "char";
188
+ columnType?: "varchar" | "text" | "char" | "uuid";
189
189
  /**
190
190
  * Rules for validating this property
191
191
  */
@@ -541,9 +541,11 @@ export interface ArrayProperty extends BaseProperty {
541
541
  ui?: ArrayUIConfig;
542
542
  type: "array";
543
543
  /**
544
- * Optional database column type. Defaults to `jsonb`.
544
+ * Optional database column type. By default, maps to a native Postgres array
545
+ * (e.g. `text[]`, `integer[]`/`numeric[]`, `boolean[]`) if the element type
546
+ * is a primitive, otherwise defaults to `jsonb`.
545
547
  */
546
- columnType?: "json" | "jsonb";
548
+ columnType?: "json" | "jsonb" | "text[]" | "integer[]" | "boolean[]" | "numeric[]";
547
549
  /**
548
550
  * The property of this array.
549
551
  * You can specify any property (except another Array property)
@@ -639,14 +641,6 @@ export interface MapProperty extends BaseProperty {
639
641
  * Properties that are displayed when rendered as a preview
640
642
  */
641
643
  previewProperties?: string[];
642
- /**
643
- * Allow the user to add only some keys in this map.
644
- * By default, all properties of the map have the corresponding field in
645
- * the form view. Setting this flag to true allows to pick only some.
646
- * Useful for map that can have a lot of sub-properties that may not be
647
- * needed
648
- */
649
- pickOnlySomeKeys?: boolean;
650
644
  /**
651
645
  * Render this map as a key-value table that allows to use
652
646
  * arbitrary keys. You don't need to define the properties in this case.
@@ -1,4 +1,4 @@
1
- import { Role, User } from "../users";
1
+ import type { User } from "../users";
2
2
  /**
3
3
  * Result of creating a new user via admin flow.
4
4
  * Contains the created user plus information about how credentials were delivered.
@@ -15,56 +15,46 @@ export interface UserCreationResult<USER extends User = User> {
15
15
  temporaryPassword?: string;
16
16
  }
17
17
  /**
18
- * Delegate to manage users, roles, and their permissions.
19
- * This interface allows the CMS to be completely agnostic of the underlying
20
- * authentication provider or backend.
18
+ * Delegate to manage auth-specific user operations.
19
+ *
20
+ * This interface allows the CMS to be agnostic of the underlying
21
+ * authentication provider or backend. User/role CRUD is now handled
22
+ * by the collection system; this delegate only exposes auth-specific
23
+ * operations (password hashing, invitations, bootstrap).
21
24
  *
22
25
  * @group Models
23
26
  */
24
27
  export interface UserManagementDelegate<USER extends User = User> {
25
28
  /**
26
- * Are the users and roles currently being fetched?
29
+ * Are auth-related operations currently loading?
27
30
  */
28
31
  loading: boolean;
29
32
  /**
30
- * List of users managed by the CMS.
33
+ * In-memory list of users (used for client-side filtering fallback).
31
34
  */
32
- users: USER[];
35
+ users?: USER[];
33
36
  /**
34
- * Optional error if users failed to load.
37
+ * Error from fetching the users list, if any.
35
38
  */
36
39
  usersError?: Error;
37
40
  /**
38
- * Function to get a user by its uid. This is used to show
39
- * user information when assigning ownership of an entity.
40
- * @param uid
41
+ * Look up a single user by UID from the in-memory cache.
41
42
  */
42
- getUser: (uid: string) => USER | null;
43
+ getUser?: (uid: string) => USER | null;
43
44
  /**
44
- * Search users with server-side pagination.
45
- * When provided, the CMS will use this for the users table
46
- * instead of loading all users into memory.
45
+ * Server-side user search with pagination.
47
46
  */
48
- searchUsers?: (options: {
47
+ searchUsers?: (params: {
49
48
  search?: string;
50
49
  limit?: number;
51
50
  offset?: number;
52
- orderBy?: string;
53
- orderDir?: "asc" | "desc";
54
- roleId?: string;
55
51
  }) => Promise<{
56
52
  users: USER[];
57
53
  total: number;
58
54
  }>;
59
- /**
60
- * Save a user (create or update)
61
- * @param user
62
- */
63
- saveUser?: (user: USER) => Promise<USER>;
64
55
  /**
65
56
  * Create a new user with invitation/password generation support.
66
57
  * Returns additional info about how the credentials were delivered.
67
- * Falls back to saveUser if not provided.
68
58
  */
69
59
  createUser?: (user: USER) => Promise<UserCreationResult<USER>>;
70
60
  /**
@@ -73,42 +63,15 @@ export interface UserManagementDelegate<USER extends User = User> {
73
63
  * or a flag indicating an email invitation was sent.
74
64
  */
75
65
  resetPassword?: (user: USER) => Promise<UserCreationResult<USER>>;
76
- /**
77
- * Delete a user
78
- * @param user
79
- */
80
- deleteUser?: (user: USER) => Promise<void>;
81
- /**
82
- * List of roles defined in the CMS.
83
- */
84
- roles?: Role[];
85
- /**
86
- * Optional error if roles failed to load.
87
- */
88
- rolesError?: Error;
89
- /**
90
- * Save a role (create or update)
91
- * @param role
92
- */
93
- saveRole?: (role: Role) => Promise<void>;
94
- /**
95
- * Delete a role
96
- * @param role
97
- */
98
- deleteRole?: (role: Role) => Promise<void>;
99
66
  /**
100
67
  * Is the currently logged in user an admin?
101
68
  */
102
69
  isAdmin?: boolean;
103
- /**
104
- * If true, the UI will allow the user to create the default roles (admin, editor, viewer).
105
- */
106
- allowDefaultRolesCreation?: boolean;
107
70
  /**
108
71
  * Optionally define roles for a given user. This is useful when the roles
109
72
  * are coming from a separate provider than the one issuing the tokens.
110
73
  */
111
- defineRolesFor?: (user: USER) => Promise<Role[] | undefined> | Role[] | undefined;
74
+ defineRolesFor?: (user: USER) => Promise<string[] | undefined> | string[] | undefined;
112
75
  /**
113
76
  * Whether any admin users exist. Used by the bootstrap banner to decide
114
77
  * whether to prompt. Populated via a lightweight check (e.g. `limit=1`
@@ -1,2 +1 @@
1
1
  export * from "./user";
2
- export * from "./roles";
@@ -35,7 +35,6 @@ export type User = {
35
35
  readonly isAnonymous: boolean;
36
36
  /**
37
37
  * Role IDs assigned to this user (e.g. ["admin", "editor"]).
38
- * These are plain string IDs — use the UserManagementDelegate to look up full Role objects.
39
38
  */
40
39
  roles?: string[];
41
40
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-core",
3
3
  "type": "module",
4
- "version": "0.3.0",
4
+ "version": "0.5.0",
5
5
  "description": "Database-Agnostic Backend Core for Rebase",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -54,10 +54,10 @@
54
54
  "ts-morph": "27.0.2",
55
55
  "ws": "^8.20.1",
56
56
  "zod": "^3.25.76",
57
- "@rebasepro/common": "0.3.0",
58
- "@rebasepro/types": "0.3.0",
59
- "@rebasepro/client": "0.3.0",
60
- "@rebasepro/utils": "0.3.0"
57
+ "@rebasepro/common": "0.5.0",
58
+ "@rebasepro/client": "0.5.0",
59
+ "@rebasepro/types": "0.5.0",
60
+ "@rebasepro/utils": "0.5.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/jest": "^29.5.14",
package/src/api/errors.ts CHANGED
@@ -70,13 +70,32 @@ export interface ErrorResponse {
70
70
  };
71
71
  }
72
72
 
73
+ /**
74
+ * General shape of errors that flow through the API error handler.
75
+ * Extends Error with optional HTTP status, error code, and details.
76
+ */
77
+ export interface RebaseApiError extends Error {
78
+ statusCode?: number;
79
+ code?: string;
80
+ details?: unknown;
81
+ }
82
+
83
+ /**
84
+ * Type guard for errors that carry optional API metadata (statusCode, code, details).
85
+ * Returns true for any Error instance — the optional properties are then
86
+ * checked via normal property access.
87
+ */
88
+ export function isRebaseApiError(error: unknown): error is RebaseApiError {
89
+ return error instanceof Error;
90
+ }
91
+
73
92
  /**
74
93
  * Hono error-handling middleware (`app.onError`).
75
94
  * Converts any error into the canonical `{ error: { message, code } }` shape.
76
95
  */
77
96
  export const errorHandler: ErrorHandler = (err, c) => {
78
97
  // Typecast custom error properties
79
- const error = err as Error & { statusCode?: number; code?: string; details?: unknown, name?: string };
98
+ const error: RebaseApiError = err;
80
99
 
81
100
  if (error instanceof ApiError || error.name === "ApiError") {
82
101
  // Operational errors — log at warn level
@@ -314,7 +314,7 @@ content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResp
314
314
  };
315
315
 
316
316
  // ── Subcollection routes ──────────────────────────────────────
317
- const relations = (collection as EntityCollection & { relations?: Relation[] }).relations;
317
+ const relations = collection.relations;
318
318
  if (relations && relations.length > 0) {
319
319
  for (const relation of relations) {
320
320
  const relationName = relation.relationName;
@@ -1,7 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import { DataDriver, Entity, EntityCollection, FetchCollectionProps, DataHooks, BackendHookContext, RestFetchService } from "@rebasepro/types";
3
3
  import { QueryOptions, HonoEnv } from "../types";
4
- import { ApiError } from "../errors";
4
+ import { ApiError, isRebaseApiError } from "../errors";
5
5
  import { parseQueryOptions } from "./query-parser";
6
6
  import { httpMethodToOperation, isOperationAllowed } from "../../auth/api-keys/api-key-permission-guard";
7
7
  import type { ApiKeyMasked } from "../../auth/api-keys/api-key-types";
@@ -87,9 +87,9 @@ export class RestApiGenerator {
87
87
  // GET /collection/count - Count entities (with optional filters)
88
88
  this.router.get(`${basePath}/count`, async (c) => {
89
89
  this.enforceApiKeyPermission(c, collection.slug);
90
- const queryDict = c.req.query();
90
+ const queryDict = c.req.queries();
91
91
  const queryOptions = parseQueryOptions(queryDict);
92
- const searchString = queryDict.searchString as string | undefined;
92
+ const searchString = Array.isArray(queryDict.searchString) ? queryDict.searchString[queryDict.searchString.length - 1] : undefined;
93
93
  const driver = c.get("driver") || this.driver;
94
94
 
95
95
  const total = await this.countRawEntities(driver, resolvedCollection, queryOptions, searchString);
@@ -99,9 +99,9 @@ export class RestApiGenerator {
99
99
  // GET /collection - List entities
100
100
  this.router.get(basePath, async (c) => {
101
101
  this.enforceApiKeyPermission(c, collection.slug);
102
- const queryDict = c.req.query();
102
+ const queryDict = c.req.queries();
103
103
  const queryOptions = parseQueryOptions(queryDict);
104
- const searchString = queryDict.searchString as string | undefined;
104
+ const searchString = Array.isArray(queryDict.searchString) ? queryDict.searchString[queryDict.searchString.length - 1] : undefined;
105
105
 
106
106
  const driver = c.get("driver") || this.driver;
107
107
  const fetchService = this.getFetchService(driver);
@@ -161,7 +161,7 @@ export class RestApiGenerator {
161
161
  this.router.get(`${basePath}/:id`, async (c) => {
162
162
  this.enforceApiKeyPermission(c, collection.slug);
163
163
  const id = c.req.param("id");
164
- const queryDict = c.req.query();
164
+ const queryDict = c.req.queries();
165
165
  const queryOptions = parseQueryOptions(queryDict);
166
166
  const driver = c.get("driver") || this.driver;
167
167
  const fetchService = this.getFetchService(driver);
@@ -234,9 +234,10 @@ export class RestApiGenerator {
234
234
 
235
235
  return c.json(response, 201);
236
236
  } catch (error) {
237
- const err = error as Error & { code?: string };
238
- err.code = err.code || "BAD_REQUEST";
239
- throw err;
237
+ if (isRebaseApiError(error) && !error.code) {
238
+ error.code = "BAD_REQUEST";
239
+ }
240
+ throw error;
240
241
  }
241
242
  });
242
243
 
@@ -282,9 +283,10 @@ export class RestApiGenerator {
282
283
 
283
284
  return c.json(response);
284
285
  } catch (error) {
285
- const err = error as Error & { code?: string };
286
- err.code = err.code || "BAD_REQUEST";
287
- throw err;
286
+ if (isRebaseApiError(error) && !error.code) {
287
+ error.code = "BAD_REQUEST";
288
+ }
289
+ throw error;
288
290
  }
289
291
  });
290
292
 
@@ -386,15 +388,18 @@ entityId };
386
388
 
387
389
  this.enforceApiKeyPermission(c, c.req.param("parent"));
388
390
 
391
+ const hookCtx = this.buildHookContext(c, "GET");
392
+
389
393
  if (parsed.entityId === "count") {
390
394
  // GET /parent/:parentId/child/count — count child entities
391
- const queryDict = c.req.query();
395
+ const queryDict = c.req.queries();
392
396
  const queryOptions = parseQueryOptions(queryDict);
397
+ const searchString = Array.isArray(queryDict.searchString) ? queryDict.searchString[queryDict.searchString.length - 1] : undefined;
393
398
 
394
399
  const total = driver.countEntities ? await driver.countEntities({
395
400
  path: parsed.collectionPath,
396
401
  filter: queryOptions.where as FetchCollectionProps["filter"],
397
- searchString: queryDict.searchString as string | undefined
402
+ searchString
398
403
  }) : 0;
399
404
 
400
405
  return c.json({ count: total });
@@ -405,26 +410,42 @@ entityId };
405
410
  entityId: parsed.entityId
406
411
  });
407
412
  if (!entity) throw ApiError.notFound("Entity not found");
408
- return c.json(this.flattenEntity(entity));
413
+
414
+ const flatResult = this.flattenEntity(entity);
415
+ const hookResult = await this.applyAfterRead(c.req.param("parent"), flatResult, hookCtx);
416
+ if (!hookResult) throw ApiError.notFound("Entity not found");
417
+
418
+ return c.json(hookResult);
409
419
  } else {
410
420
  // GET /parent/:parentId/child — list entities
411
- const queryDict = c.req.query();
421
+ const queryDict = c.req.queries();
412
422
  const queryOptions = parseQueryOptions(queryDict);
423
+ const searchString = Array.isArray(queryDict.searchString) ? queryDict.searchString[queryDict.searchString.length - 1] : undefined;
413
424
  const entities = await driver.fetchCollection({
414
425
  path: parsed.collectionPath,
415
426
  filter: queryOptions.where as FetchCollectionProps["filter"],
416
427
  limit: queryOptions.limit,
417
428
  orderBy: queryOptions.orderBy?.[0]?.field,
418
429
  order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
419
- searchString: queryDict.searchString as string | undefined
430
+ searchString
420
431
  });
432
+
433
+ let flatEntities = entities.map(e => this.flattenEntity(e));
434
+ flatEntities = await this.applyAfterReadBatch(c.req.param("parent"), flatEntities, hookCtx);
435
+
436
+ const total = driver.countEntities ? await driver.countEntities({
437
+ path: parsed.collectionPath,
438
+ filter: queryOptions.where as FetchCollectionProps["filter"],
439
+ searchString
440
+ }) : flatEntities.length;
441
+
421
442
  return c.json({
422
- data: entities.map(e => this.flattenEntity(e)),
443
+ data: flatEntities,
423
444
  meta: {
424
- total: entities.length,
445
+ total,
425
446
  limit: queryOptions.limit,
426
447
  offset: queryOptions.offset,
427
- hasMore: false
448
+ hasMore: (queryOptions.offset || 0) + flatEntities.length < total
428
449
  }
429
450
  });
430
451
  }
@@ -439,9 +460,14 @@ entityId };
439
460
  if (!parsed || parsed.entityId) return next();
440
461
 
441
462
  const driver = c.get("driver") || this.driver;
463
+ const hookCtx = this.buildHookContext(c, "POST");
442
464
 
443
465
  this.enforceApiKeyPermission(c, c.req.param("parent"));
444
- const body = await c.req.json().catch(() => ({}));
466
+ let body = await c.req.json().catch(() => ({}));
467
+
468
+ if (this.dataHooks?.beforeSave) {
469
+ body = await this.dataHooks.beforeSave(parsed.collectionPath, body, undefined, hookCtx);
470
+ }
445
471
 
446
472
  const entity = await driver.saveEntity({
447
473
  path: parsed.collectionPath,
@@ -449,7 +475,15 @@ entityId };
449
475
  status: "new"
450
476
  });
451
477
 
452
- return c.json(this.formatResponse(entity), 201);
478
+ const response = this.formatResponse(entity);
479
+
480
+ if (this.dataHooks?.afterSave) {
481
+ Promise.resolve(this.dataHooks.afterSave(parsed.collectionPath, response as Record<string, unknown>, hookCtx)).catch(err => {
482
+ console.error("[BackendHooks] data.afterSave error:", err instanceof Error ? err.message : err);
483
+ });
484
+ }
485
+
486
+ return c.json(response, 201);
453
487
  });
454
488
 
455
489
  // PUT /<subcollection-path>/:id — update entity
@@ -461,10 +495,15 @@ entityId };
461
495
  if (!parsed || !parsed.entityId) return next();
462
496
 
463
497
  const driver = c.get("driver") || this.driver;
498
+ const hookCtx = this.buildHookContext(c, "PUT");
464
499
 
465
500
  this.enforceApiKeyPermission(c, c.req.param("parent"));
466
501
 
467
- const body = await c.req.json().catch(() => ({}));
502
+ let body = await c.req.json().catch(() => ({}));
503
+
504
+ if (this.dataHooks?.beforeSave) {
505
+ body = await this.dataHooks.beforeSave(parsed.collectionPath, body, parsed.entityId, hookCtx);
506
+ }
468
507
 
469
508
  const entity = await driver.saveEntity({
470
509
  path: parsed.collectionPath,
@@ -473,7 +512,15 @@ entityId };
473
512
  status: "existing"
474
513
  });
475
514
 
476
- return c.json(this.formatResponse(entity));
515
+ const response = this.formatResponse(entity);
516
+
517
+ if (this.dataHooks?.afterSave) {
518
+ Promise.resolve(this.dataHooks.afterSave(parsed.collectionPath, response as Record<string, unknown>, hookCtx)).catch(err => {
519
+ console.error("[BackendHooks] data.afterSave error:", err instanceof Error ? err.message : err);
520
+ });
521
+ }
522
+
523
+ return c.json(response);
477
524
  });
478
525
 
479
526
  // DELETE /<subcollection-path>/:id — delete entity
@@ -485,6 +532,7 @@ entityId };
485
532
  if (!parsed || !parsed.entityId) return next();
486
533
 
487
534
  const driver = c.get("driver") || this.driver;
535
+ const hookCtx = this.buildHookContext(c, "DELETE");
488
536
 
489
537
  this.enforceApiKeyPermission(c, c.req.param("parent"));
490
538
 
@@ -495,8 +543,18 @@ entityId };
495
543
 
496
544
  if (!existingEntity) throw ApiError.notFound("Entity not found");
497
545
 
546
+ if (this.dataHooks?.beforeDelete) {
547
+ await this.dataHooks.beforeDelete(parsed.collectionPath, parsed.entityId, hookCtx);
548
+ }
549
+
498
550
  await driver.deleteEntity({ entity: existingEntity });
499
551
 
552
+ if (this.dataHooks?.afterDelete) {
553
+ Promise.resolve(this.dataHooks.afterDelete(parsed.collectionPath, parsed.entityId, hookCtx)).catch(err => {
554
+ console.error("[BackendHooks] data.afterDelete error:", err instanceof Error ? err.message : err);
555
+ });
556
+ }
557
+
500
558
  return new Response(null, { status: 204 });
501
559
  });
502
560
  }