@edium/halifax 1.0.0 → 2.1.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 (150) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +72 -50
  3. package/README_AUTOCRUD.md +94 -19
  4. package/README_QUERYBUILDER.md +1 -1
  5. package/README_REPO_ADAPTERS.md +80 -11
  6. package/dist/adapters/http/ExpressAdapter.d.ts +34 -5
  7. package/dist/adapters/http/ExpressAdapter.js +20 -12
  8. package/dist/adapters/http/FastifyAdapter.d.ts +93 -0
  9. package/dist/adapters/http/FastifyAdapter.js +125 -0
  10. package/dist/adapters/http/HyperExpressAdapter.d.ts +82 -0
  11. package/dist/adapters/http/HyperExpressAdapter.js +128 -0
  12. package/dist/adapters/http/UltimateExpressAdapter.d.ts +84 -0
  13. package/dist/adapters/http/UltimateExpressAdapter.js +108 -0
  14. package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +89 -40
  15. package/dist/adapters/orm/prisma/PrismaAdapter.js +233 -71
  16. package/dist/adapters/orm/prisma/astToPrisma.d.ts +26 -0
  17. package/dist/adapters/orm/prisma/astToPrisma.js +140 -0
  18. package/dist/adapters/orm/prisma/createPrismaResources.d.ts +1 -2
  19. package/dist/adapters/orm/prisma/createPrismaResources.js +10 -6
  20. package/dist/adapters/orm/prisma/helpers.d.ts +0 -1
  21. package/dist/adapters/orm/prisma/helpers.js +0 -1
  22. package/dist/adapters/orm/prisma/index.d.ts +1 -2
  23. package/dist/adapters/orm/prisma/index.js +0 -1
  24. package/dist/adapters/orm/prisma/types.d.ts +14 -9
  25. package/dist/adapters/orm/prisma/types.js +0 -1
  26. package/dist/auth/AuthStrategy.d.ts +0 -9
  27. package/dist/auth/AuthStrategy.js +0 -7
  28. package/dist/core/cache/CacheStore.d.ts +25 -0
  29. package/dist/core/cache/CacheStore.js +1 -0
  30. package/dist/core/cache/createCachingRepository.d.ts +39 -0
  31. package/dist/core/cache/createCachingRepository.js +116 -0
  32. package/dist/core/cache/in-memory/InMemoryCacheStore.d.ts +19 -0
  33. package/dist/core/cache/in-memory/InMemoryCacheStore.js +34 -0
  34. package/dist/core/cache/index.d.ts +5 -0
  35. package/dist/core/cache/index.js +5 -0
  36. package/dist/core/cache/redis/RedisCacheStore.d.ts +28 -0
  37. package/dist/core/cache/redis/RedisCacheStore.js +42 -0
  38. package/dist/core/cache/redis/RedisLikeClient.d.ts +12 -0
  39. package/dist/core/cache/redis/RedisLikeClient.js +1 -0
  40. package/dist/core/crudRouter.d.ts +72 -8
  41. package/dist/core/crudRouter.js +266 -105
  42. package/dist/core/queryString.d.ts +3 -3
  43. package/dist/core/queryString.js +16 -7
  44. package/dist/core/types.d.ts +151 -31
  45. package/dist/core/types.js +13 -1
  46. package/dist/core/validation.d.ts +12 -4
  47. package/dist/core/validation.js +33 -13
  48. package/dist/enums/SqlComparison.d.ts +13 -3
  49. package/dist/enums/SqlComparison.js +12 -2
  50. package/dist/enums/SqlOperator.d.ts +0 -1
  51. package/dist/enums/SqlOperator.js +0 -1
  52. package/dist/enums/SqlOrder.d.ts +0 -1
  53. package/dist/enums/SqlOrder.js +0 -1
  54. package/dist/errors/AuthenticationError.d.ts +0 -1
  55. package/dist/errors/AuthenticationError.js +0 -1
  56. package/dist/errors/AuthorizationError.d.ts +0 -1
  57. package/dist/errors/AuthorizationError.js +0 -1
  58. package/dist/errors/BadRequestError.d.ts +0 -1
  59. package/dist/errors/BadRequestError.js +0 -1
  60. package/dist/errors/HttpError.d.ts +0 -1
  61. package/dist/errors/HttpError.js +0 -1
  62. package/dist/errors/MethodNotAllowedError.d.ts +0 -1
  63. package/dist/errors/MethodNotAllowedError.js +0 -1
  64. package/dist/errors/NotAcceptableError.d.ts +0 -1
  65. package/dist/errors/NotAcceptableError.js +0 -1
  66. package/dist/errors/NotFoundError.d.ts +0 -1
  67. package/dist/errors/NotFoundError.js +0 -1
  68. package/dist/errors/NotImplementedError.d.ts +0 -1
  69. package/dist/errors/NotImplementedError.js +0 -1
  70. package/dist/errors/ServerError.d.ts +0 -1
  71. package/dist/errors/ServerError.js +0 -1
  72. package/dist/errors/UnprocessableEntityError.d.ts +0 -1
  73. package/dist/errors/UnprocessableEntityError.js +0 -1
  74. package/dist/errors/UnsupportedMediaTypeError.d.ts +0 -1
  75. package/dist/errors/UnsupportedMediaTypeError.js +0 -1
  76. package/dist/index.d.ts +1 -3
  77. package/dist/index.js +1 -3
  78. package/dist/interfaces/IQueryFilter.d.ts +1 -2
  79. package/dist/interfaces/IQueryFilter.js +0 -1
  80. package/dist/interfaces/IQueryOptions.d.ts +9 -9
  81. package/dist/interfaces/IQueryOptions.js +0 -1
  82. package/dist/interfaces/ISort.d.ts +0 -1
  83. package/dist/interfaces/ISort.js +0 -1
  84. package/package.json +10 -8
  85. package/dist/adapters/http/ExpressAdapter.d.ts.map +0 -1
  86. package/dist/adapters/http/ExpressAdapter.js.map +0 -1
  87. package/dist/adapters/orm/prisma/PrismaAdapter.d.ts.map +0 -1
  88. package/dist/adapters/orm/prisma/PrismaAdapter.js.map +0 -1
  89. package/dist/adapters/orm/prisma/createPrismaResources.d.ts.map +0 -1
  90. package/dist/adapters/orm/prisma/createPrismaResources.js.map +0 -1
  91. package/dist/adapters/orm/prisma/helpers.d.ts.map +0 -1
  92. package/dist/adapters/orm/prisma/helpers.js.map +0 -1
  93. package/dist/adapters/orm/prisma/index.d.ts.map +0 -1
  94. package/dist/adapters/orm/prisma/index.js.map +0 -1
  95. package/dist/adapters/orm/prisma/types.d.ts.map +0 -1
  96. package/dist/adapters/orm/prisma/types.js.map +0 -1
  97. package/dist/auth/AuthStrategy.d.ts.map +0 -1
  98. package/dist/auth/AuthStrategy.js.map +0 -1
  99. package/dist/classes/QueryBuilder.d.ts +0 -33
  100. package/dist/classes/QueryBuilder.d.ts.map +0 -1
  101. package/dist/classes/QueryBuilder.js +0 -262
  102. package/dist/classes/QueryBuilder.js.map +0 -1
  103. package/dist/core/crudRouter.d.ts.map +0 -1
  104. package/dist/core/crudRouter.js.map +0 -1
  105. package/dist/core/queryString.d.ts.map +0 -1
  106. package/dist/core/queryString.js.map +0 -1
  107. package/dist/core/types.d.ts.map +0 -1
  108. package/dist/core/types.js.map +0 -1
  109. package/dist/core/validation.d.ts.map +0 -1
  110. package/dist/core/validation.js.map +0 -1
  111. package/dist/enums/SqlComparison.d.ts.map +0 -1
  112. package/dist/enums/SqlComparison.js.map +0 -1
  113. package/dist/enums/SqlOperator.d.ts.map +0 -1
  114. package/dist/enums/SqlOperator.js.map +0 -1
  115. package/dist/enums/SqlOrder.d.ts.map +0 -1
  116. package/dist/enums/SqlOrder.js.map +0 -1
  117. package/dist/errors/AuthenticationError.d.ts.map +0 -1
  118. package/dist/errors/AuthenticationError.js.map +0 -1
  119. package/dist/errors/AuthorizationError.d.ts.map +0 -1
  120. package/dist/errors/AuthorizationError.js.map +0 -1
  121. package/dist/errors/BadRequestError.d.ts.map +0 -1
  122. package/dist/errors/BadRequestError.js.map +0 -1
  123. package/dist/errors/HttpError.d.ts.map +0 -1
  124. package/dist/errors/HttpError.js.map +0 -1
  125. package/dist/errors/MethodNotAllowedError.d.ts.map +0 -1
  126. package/dist/errors/MethodNotAllowedError.js.map +0 -1
  127. package/dist/errors/NotAcceptableError.d.ts.map +0 -1
  128. package/dist/errors/NotAcceptableError.js.map +0 -1
  129. package/dist/errors/NotFoundError.d.ts.map +0 -1
  130. package/dist/errors/NotFoundError.js.map +0 -1
  131. package/dist/errors/NotImplementedError.d.ts.map +0 -1
  132. package/dist/errors/NotImplementedError.js.map +0 -1
  133. package/dist/errors/ServerError.d.ts.map +0 -1
  134. package/dist/errors/ServerError.js.map +0 -1
  135. package/dist/errors/UnprocessableEntityError.d.ts.map +0 -1
  136. package/dist/errors/UnprocessableEntityError.js.map +0 -1
  137. package/dist/errors/UnsupportedMediaTypeError.d.ts.map +0 -1
  138. package/dist/errors/UnsupportedMediaTypeError.js.map +0 -1
  139. package/dist/index.d.ts.map +0 -1
  140. package/dist/index.js.map +0 -1
  141. package/dist/interfaces/IParamQuery.d.ts +0 -8
  142. package/dist/interfaces/IParamQuery.d.ts.map +0 -1
  143. package/dist/interfaces/IParamQuery.js +0 -2
  144. package/dist/interfaces/IParamQuery.js.map +0 -1
  145. package/dist/interfaces/IQueryFilter.d.ts.map +0 -1
  146. package/dist/interfaces/IQueryFilter.js.map +0 -1
  147. package/dist/interfaces/IQueryOptions.d.ts.map +0 -1
  148. package/dist/interfaces/IQueryOptions.js.map +0 -1
  149. package/dist/interfaces/ISort.d.ts.map +0 -1
  150. package/dist/interfaces/ISort.js.map +0 -1
@@ -1,6 +1,5 @@
1
1
  import { AllowAllAuthStrategy } from '../auth/AuthStrategy.js';
2
- import { QueryBuilder } from '../classes/QueryBuilder.js';
3
- import { BadRequestError } from '../errors/BadRequestError.js';
2
+ import { createCachingRepository, InMemoryCacheStore } from '../core/cache/index.js';
4
3
  import { HttpError } from '../errors/HttpError.js';
5
4
  import { MethodNotAllowedError } from '../errors/MethodNotAllowedError.js';
6
5
  import { NotAcceptableError } from '../errors/NotAcceptableError.js';
@@ -10,41 +9,122 @@ import { UnprocessableEntityError } from '../errors/UnprocessableEntityError.js'
10
9
  import { UnsupportedMediaTypeError } from '../errors/UnsupportedMediaTypeError.js';
11
10
  import { defaultCrudPermissions } from '../core/types.js';
12
11
  import { parseListOptions } from '../core/queryString.js';
13
- import { validateAdvancedQuery, validateId, isValidUuid } from '../core/validation.js';
12
+ import { validateAdvancedQuery, validateId, isValidUuid, isValidObjectId } from '../core/validation.js';
14
13
  import { ServerError } from '../errors/ServerError.js';
15
14
  import { AuthorizationError } from '../errors/AuthorizationError.js';
16
15
  /**
17
16
  * Parses and validates a raw `:id` route parameter.
18
17
  * @param raw - The raw string value from `req.params.id`.
19
- * @returns A parsed integer for numeric IDs, or the original string for UUIDs.
20
- * @throws {@link BadRequestError} when the value is not a valid integer or UUID.
18
+ * @returns A parsed integer for numeric IDs, or the original string for UUID / ObjectId keys.
19
+ * @throws {@link BadRequestError} when the value is not a valid integer, UUID, or ObjectId.
21
20
  */
22
21
  function parseId(raw) {
23
22
  validateId(raw);
24
- if (typeof raw === 'string' && isValidUuid(raw))
23
+ // UUID and Mongo ObjectId keys are passed through as strings; only numeric ids are parsed.
24
+ if (typeof raw === 'string' && (isValidUuid(raw) || isValidObjectId(raw)))
25
25
  return raw;
26
26
  return typeof raw === 'string' ? parseInt(raw, 10) : raw;
27
27
  }
28
28
  /**
29
29
  * Strips non-writable fields from a request body and rejects unknown fields with a 422.
30
- * Only fields explicitly marked `writable: true` are allowed through; fields with
31
- * `writable: false` or `writable` unset are silently dropped.
32
- * @param resource - The resource definition that defines writable fields.
30
+ * Operates on a {@link normalizeResource | normalized} resource, whose fields carry explicit
31
+ * `writable` booleans (permissive by default; the primary key protected). Fields with
32
+ * `writable: false` are silently dropped.
33
+ * @param resource - The normalized resource definition.
33
34
  * @param data - The raw request body key-value map.
34
- * @returns A new object containing only explicitly writable fields.
35
+ * @returns A new object containing only writable fields.
35
36
  * @throws {@link UnprocessableEntityError} when the body contains keys not defined on the resource.
36
37
  */
37
38
  function filterWritableFields(resource, data) {
38
- const knownFields = new Set(resource.fields.map((f) => f.name));
39
+ const fields = resource.fields ?? [];
40
+ const knownFields = new Set(fields.map((f) => f.name));
39
41
  const unknownFields = Object.keys(data).filter((key) => !knownFields.has(key));
40
42
  if (unknownFields.length) {
41
43
  throw new UnprocessableEntityError(`Unknown field(s): ${unknownFields.join(', ')}.`);
42
44
  }
43
45
  return Object.fromEntries(Object.entries(data).filter(([key]) => {
44
- const field = resource.fields.find((f) => f.name === key);
45
- return field?.writable === true;
46
+ const field = fields.find((f) => f.name === key);
47
+ return field?.writable !== false;
46
48
  }));
47
49
  }
50
+ /**
51
+ * Derives a human-readable resource name from a route prefix when none is given:
52
+ * de-kebabs/de-snakes and title-cases each word (`'blog-posts'` → `'Blog Posts'`).
53
+ * @param routePrefix - The resource's URL path segment.
54
+ * @returns A title-cased display name.
55
+ */
56
+ function deriveResourceName(routePrefix) {
57
+ return (routePrefix
58
+ .split(/[-_/\s]+/)
59
+ .filter(Boolean)
60
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
61
+ .join(' ') || routePrefix);
62
+ }
63
+ /**
64
+ * Resolves the effective field list for a resource. When the repository exposes a field
65
+ * schema it forms the base, and the resource's own `fields` are merged over it **by name**
66
+ * as sparse overrides (entries with no matching base field are added). When the repository
67
+ * exposes no schema, the resource's `fields` are the authoritative list.
68
+ *
69
+ * Every resolved field gets explicit, permissive defaults — `filterable`/`sortable`/
70
+ * `selectable`/`writable` default to `true` — except the primary key, which is non-writable
71
+ * unless a field entry explicitly sets `writable: true`.
72
+ *
73
+ * @param resource - The raw resource definition.
74
+ * @param idField - The repository's primary-key field name (protected from writes).
75
+ * @returns The fully-resolved field list.
76
+ * @throws {@link ServerError} when neither the repository nor the resource provides any fields.
77
+ */
78
+ function resolveFields(resource, idField) {
79
+ const byName = new Map();
80
+ for (const field of resource.repository?.fields ?? [])
81
+ byName.set(field.name, { ...field });
82
+ for (const override of resource.fields ?? [])
83
+ byName.set(override.name, { ...byName.get(override.name), ...override });
84
+ const merged = [...byName.values()];
85
+ if (merged.length === 0) {
86
+ throw new ServerError(`Resource '${resource.name ?? resource.routePrefix}' has no fields. Provide 'fields', ` +
87
+ `or construct its repository with a model so the schema can be derived.`);
88
+ }
89
+ return merged.map((field) => ({
90
+ name: field.name,
91
+ filterable: field.filterable !== false,
92
+ sortable: field.sortable !== false,
93
+ selectable: field.selectable !== false,
94
+ // Permissive by default — but the primary key is protected: writable only when opted in.
95
+ writable: field.name === idField ? field.writable === true : field.writable !== false
96
+ }));
97
+ }
98
+ /**
99
+ * Merges the repository's relation schema with the resource's own relations, by name.
100
+ * @param resource - The raw resource definition.
101
+ * @returns The resolved relation list (may be empty).
102
+ */
103
+ function resolveRelations(resource) {
104
+ const byName = new Map();
105
+ for (const relation of resource.repository?.relations ?? [])
106
+ byName.set(relation.name, relation);
107
+ for (const relation of resource.relations ?? [])
108
+ byName.set(relation.name, relation);
109
+ return [...byName.values()];
110
+ }
111
+ /**
112
+ * Produces a fully-resolved resource: `name` filled in (derived from `routePrefix` when
113
+ * omitted), and `fields`/`relations` resolved from the repository schema + the resource's
114
+ * own (override) entries. Every downstream router stage operates on this normalized form,
115
+ * so defaults live in exactly one place.
116
+ * @param resource - The raw resource definition supplied by the caller.
117
+ * @returns A normalized resource with guaranteed `name` and resolved `fields`/`relations`.
118
+ */
119
+ function normalizeResource(resource) {
120
+ const idField = resource.repository?.idField ?? 'id';
121
+ return {
122
+ ...resource,
123
+ name: resource.name ?? deriveResourceName(resource.routePrefix),
124
+ fields: resolveFields(resource, idField),
125
+ relations: resolveRelations(resource)
126
+ };
127
+ }
48
128
  /** Maps HTTP status codes to machine-readable error code strings. */
49
129
  const statusCodeMap = {
50
130
  400: 'BAD_REQUEST',
@@ -77,35 +157,6 @@ export function normalizeError(error) {
77
157
  }
78
158
  return { status: 500, code: 'INTERNAL_ERROR', message: 'Internal server error' };
79
159
  }
80
- /**
81
- * Validates that all identifier-shaped values in a preview query are safe SQL identifiers.
82
- * Field names and the table name are interpolated directly into SQL strings (not parameterized),
83
- * so they must match `[a-zA-Z_][a-zA-Z0-9_.]*` to prevent unexpected SQL fragments.
84
- * @param query - The query AST to inspect.
85
- * @throws {@link BadRequestError} when any identifier contains disallowed characters.
86
- */
87
- function assertSafePreviewIdentifiers(query) {
88
- const safe = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
89
- const check = (value, label) => {
90
- if (!safe.test(value))
91
- throw new BadRequestError(`Invalid ${label}: '${value}'.`);
92
- };
93
- if (query.tableName)
94
- check(query.tableName, 'table name');
95
- for (const f of query.fields ?? [])
96
- check(f, 'field name');
97
- for (const s of query.orderBy ?? [])
98
- check(s.field, 'sort field');
99
- const checkFilters = (filters) => {
100
- for (const f of filters) {
101
- if (f.field)
102
- check(f.field, 'filter field');
103
- if (f.children?.length)
104
- checkFilters(f.children);
105
- }
106
- };
107
- checkFilters(query.where ?? []);
108
- }
109
160
  /**
110
161
  * Serialises a caught error and writes it as a JSON `{ errors: [...] }` response.
111
162
  * @param error - The caught value to serialise.
@@ -118,12 +169,35 @@ async function sendError(error, res) {
118
169
  item['details'] = details;
119
170
  await res.status(status).json({ errors: [item] });
120
171
  }
172
+ /**
173
+ * Resolves the effective envelope key: a non-empty string enables wrapping; `null`, `undefined`,
174
+ * and `''` all mean "no envelope" (an empty key would produce a meaningless `{ "": body }`).
175
+ * @param value - The per-resource or API-wide envelope setting.
176
+ * @returns The envelope key, or `null` when responses should be sent bare.
177
+ */
178
+ function normalizeEnvelope(value) {
179
+ return typeof value === 'string' && value.length > 0 ? value : null;
180
+ }
181
+ /**
182
+ * Writes a success body as JSON, wrapping it under `envelope` when one is configured.
183
+ * The single seam through which every success response is serialised, so the envelope is
184
+ * applied consistently and exactly once. Applied at the response boundary (after the cache),
185
+ * so cached payloads stay envelope-agnostic.
186
+ * @param res - The response object to write to.
187
+ * @param status - HTTP status code to send.
188
+ * @param body - The success payload (wrapped under `envelope` when set).
189
+ * @param envelope - Resolved envelope key, or `null` to send the body bare.
190
+ */
191
+ function writeSuccess(res, status, body, envelope) {
192
+ return res.status(status).json(envelope ? { [envelope]: body } : body);
193
+ }
121
194
  /**
122
195
  * Runs the auth strategy for `action` and throws {@link AuthorizationError} when not allowed.
123
196
  * @param req - The incoming HTTP request.
124
197
  * @param resource - The resource being accessed (used to look up required permissions).
125
198
  * @param action - The CRUD action being performed.
126
199
  * @param authStrategy - The active auth strategy.
200
+ * @returns The resolved {@link AuthContext} (so callers can derive the tenant scope from it).
127
201
  */
128
202
  async function authorizeRequest(req, resource, action, authStrategy) {
129
203
  const auth = await authStrategy.authenticate(req);
@@ -138,7 +212,7 @@ async function authorizeRequest(req, resource, action, authStrategy) {
138
212
  });
139
213
  if (!allowed)
140
214
  throw new AuthorizationError();
141
- return;
215
+ return auth;
142
216
  }
143
217
  if (requiredPermissions.length) {
144
218
  const permissions = new Set(auth.permissions ?? []);
@@ -147,7 +221,28 @@ async function authorizeRequest(req, resource, action, authStrategy) {
147
221
  if (!allowed)
148
222
  throw new AuthorizationError();
149
223
  }
224
+ return auth;
150
225
  }
226
+ /**
227
+ * Determines the column a resource is tenant-scoped on, applying this precedence:
228
+ * explicit `resource.tenant` (or `false` to opt out) → auto-detect the API's default
229
+ * tenant field when the resource actually has it → otherwise unscoped (global).
230
+ * @param resource - The resource being inspected.
231
+ * @param tenant - The API-wide tenant options, or `undefined` when tenancy is off.
232
+ * @returns The tenant field name, or `null` when the resource is not scoped.
233
+ */
234
+ function effectiveTenantField(resource, tenant) {
235
+ if (!tenant)
236
+ return null;
237
+ if (resource.tenant === false)
238
+ return null;
239
+ if (resource.tenant && resource.tenant.field)
240
+ return resource.tenant.field;
241
+ const fallback = tenant.field ?? 'tenantId';
242
+ return (resource.fields ?? []).some((f) => f.name === fallback) ? fallback : null;
243
+ }
244
+ /** Matches a safe SQL identifier — tenant fields are interpolated into SQL on bulk paths. */
245
+ const safeIdentifier = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
151
246
  /**
152
247
  * Reads a single header value by name (case-insensitive).
153
248
  * @param req - The incoming HTTP request.
@@ -159,6 +254,22 @@ function getHeaderValue(req, name) {
159
254
  const value = Array.isArray(raw) ? raw[0] : raw;
160
255
  return typeof value === 'string' ? value : undefined;
161
256
  }
257
+ /**
258
+ * Decides whether the request asks to bypass (bust) the cache. For the standard
259
+ * `Cache-Control` header this means a `no-cache`/`no-store` directive; for any custom
260
+ * bust header, mere presence (with a non-empty value) triggers a refresh.
261
+ * @param req - The incoming HTTP request.
262
+ * @param header - The configured bust header name.
263
+ * @returns `true` when the cache should be force-refreshed for this request.
264
+ */
265
+ function wantsCacheBust(req, header) {
266
+ const value = getHeaderValue(req, header);
267
+ if (!value)
268
+ return false;
269
+ if (header.toLowerCase() === 'cache-control')
270
+ return /no-cache|no-store/i.test(value);
271
+ return true;
272
+ }
162
273
  /**
163
274
  * Throws {@link UnsupportedMediaTypeError} when a body-carrying request uses a non-JSON Content-Type.
164
275
  * @param req - The incoming HTTP request to check.
@@ -209,141 +320,201 @@ function wrap(handler) {
209
320
  * Registers all CRUD routes for every resource on the given HTTP server.
210
321
  *
211
322
  * Routes are controlled by `resource.permissions` merged with {@link defaultCrudPermissions}.
212
- * A global query-builder preview endpoint is also registered at `previewQueryBuilderPath`.
213
323
  *
214
324
  * @param server - The HTTP server adapter to register routes on (e.g. {@link ExpressHttpServer}).
215
325
  * @param resources - Resource definitions to wire up as CRUD endpoints.
216
- * @param options - Auth strategy, query-builder path overrides, and preview path overrides.
326
+ * @param options - Auth strategy and query-builder path overrides.
217
327
  */
218
328
  export function registerCrudApi(server, resources, options = {}) {
219
329
  const authStrategy = options.authStrategy ?? new AllowAllAuthStrategy();
220
- const queryBuilderPath = options.queryBuilderPath ?? 'query-builder';
221
- const previewPath = options.previewQueryBuilderPath ?? '/query-builder/preview';
222
- resources.forEach((resource) => {
223
- const repository = resource.repository;
330
+ const queryBuilderPath = options.queryBuilderPath ?? 'query';
331
+ // One shared cache store for the whole API (created lazily only if any resource caches),
332
+ // so version-based invalidation is consistent across requests and resources.
333
+ const cacheStore = options.cache?.store ?? new InMemoryCacheStore();
334
+ const bustHeader = options.cache?.bustHeader ?? 'cache-control';
335
+ resources.forEach((rawResource) => {
336
+ const repository = rawResource.repository;
224
337
  if (!repository)
225
- throw new ServerError(`Resource '${resource.name}' does not define a repository.`);
338
+ throw new ServerError(`Resource '${rawResource.name ?? rawResource.routePrefix}' does not define a repository.`);
339
+ // Resolve name + field/relation schema once, here, so every downstream stage (validation,
340
+ // write-filtering, tenant auto-detection, cache namespace) works off a single source of
341
+ // truth with all defaults already applied.
342
+ const resource = normalizeResource(rawResource);
343
+ // Resolve the response envelope once: an explicit per-resource setting wins over the
344
+ // API-wide default — including an explicit `null`/`''`, which opts this resource out.
345
+ const envelope = normalizeEnvelope(resource.envelope !== undefined ? resource.envelope : options.envelope);
346
+ // Resolve tenancy once at registration and fail closed on misconfiguration, so a
347
+ // scoped resource can never be silently served unscoped at request time.
348
+ const tenantField = effectiveTenantField(resource, options.tenant);
349
+ if (tenantField) {
350
+ if (!safeIdentifier.test(tenantField))
351
+ throw new ServerError(`Resource '${resource.name}' has an unsafe tenant field name '${tenantField}'.`);
352
+ if (!repository.withScope)
353
+ throw new ServerError(`Resource '${resource.name}' is tenant-scoped on '${tenantField}' but its repository ` +
354
+ `does not implement withScope(). Refusing to serve it unscoped.`);
355
+ }
356
+ // Effective cache TTL: an explicit `cache: false` disables; otherwise per-resource config
357
+ // wins over the API-wide default. `0` means "never expire" (so `undefined` = no caching).
358
+ const cacheTtl = resource.cache === false
359
+ ? undefined
360
+ : (resource.cache?.ttlSeconds ?? options.cache?.ttlSeconds);
361
+ const cachingEnabled = cacheTtl !== undefined;
362
+ /**
363
+ * Wraps a repository in the read-through cache when caching is enabled for this resource.
364
+ * The namespace embeds the resource name and tenant key, so one tenant can never read
365
+ * another's cached rows. `bust` (from the cache-bust header) force-refreshes this request.
366
+ * @param repo - The (possibly tenant-scoped) repository for this request.
367
+ * @param scopeKey - Stable key for the current tenant scope (or `'global'`).
368
+ * @param bust - Whether the caller requested a cache-busting refresh.
369
+ * @returns The repository, cache-wrapped when caching is enabled.
370
+ */
371
+ const withCache = (repo, scopeKey, bust) => cachingEnabled
372
+ ? createCachingRepository(repo, {
373
+ store: cacheStore,
374
+ ttlSeconds: cacheTtl,
375
+ namespace: `${resource.name}:${scopeKey}`,
376
+ bust
377
+ })
378
+ : repo;
379
+ /**
380
+ * Returns the repository to use for this request: the tenant-scoped clone when the
381
+ * resource is scoped, or the bare repository otherwise — wrapped in the read-through
382
+ * cache when enabled. Throws 403 in strict mode (the default) when a scoped resource
383
+ * cannot resolve a tenant for the caller.
384
+ */
385
+ const resolveRepo = async (req, auth) => {
386
+ const bust = cachingEnabled && wantsCacheBust(req, bustHeader);
387
+ if (!tenantField || !options.tenant)
388
+ return withCache(repository, 'global', bust);
389
+ const value = await options.tenant.resolveId({ auth, req, resource });
390
+ if (value === undefined || value === null || value === '') {
391
+ if (options.tenant.strict !== false)
392
+ throw new AuthorizationError('No tenant is associated with this request.');
393
+ return withCache(repository, 'global', bust);
394
+ }
395
+ return withCache(repository.withScope({ field: tenantField, value }), String(value), bust);
396
+ };
226
397
  const permissions = { ...defaultCrudPermissions, ...resource.permissions };
227
398
  const basePath = `/${resource.routePrefix}`;
228
399
  if (permissions.allowCreate) {
229
400
  server.registerRoute('POST', basePath, wrap(async (req, res) => {
230
- await authorizeRequest(req, resource, 'create', authStrategy);
401
+ const auth = await authorizeRequest(req, resource, 'create', authStrategy);
402
+ const repo = await resolveRepo(req, auth);
231
403
  const idempotencyKey = getHeaderValue(req, 'idempotency-key');
232
404
  const createOptions = idempotencyKey ? { idempotencyKey } : undefined;
233
405
  const items = (Array.isArray(req.body) ? req.body : [req.body]).map((item) => filterWritableFields(resource, item));
234
406
  if (items.length === 1) {
235
- const result = await repository.createOne(items[0], createOptions);
236
- await res.status(201).json(result);
407
+ const result = await repo.createOne(items[0], createOptions);
408
+ await writeSuccess(res, 201, result, envelope);
237
409
  return;
238
410
  }
239
- const results = await repository.createMany(items, createOptions);
240
- await res.status(201).json(results);
411
+ const results = await repo.createMany(items, createOptions);
412
+ await writeSuccess(res, 201, results, envelope);
241
413
  }));
242
414
  }
243
415
  if (permissions.allowReadMany) {
244
416
  server.registerRoute('GET', basePath, wrap(async (req, res) => {
245
- await authorizeRequest(req, resource, 'readMany', authStrategy);
417
+ const auth = await authorizeRequest(req, resource, 'readMany', authStrategy);
418
+ const repo = await resolveRepo(req, auth);
246
419
  const listOptions = parseListOptions(req.query, resource);
247
- const results = await repository.getMany(listOptions);
248
- await res.status(200).json(results);
420
+ const results = await repo.getMany(listOptions);
421
+ await writeSuccess(res, 200, results, envelope);
249
422
  }));
250
423
  }
251
424
  if (permissions.allowReadManyWithQueryBuilder) {
252
425
  server.registerRoute('POST', `${basePath}/${queryBuilderPath}`, wrap(async (req, res) => {
253
- await authorizeRequest(req, resource, 'readManyWithQueryBuilder', authStrategy);
254
- if (!repository.executeQueryBuilder)
426
+ const auth = await authorizeRequest(req, resource, 'readManyWithQueryBuilder', authStrategy);
427
+ const repo = await resolveRepo(req, auth);
428
+ if (!repo.executeQuery)
255
429
  throw new NotImplementedError('This resource does not support the query builder.');
256
430
  const body = (req.body ?? {});
257
- const query = {
258
- ...body,
259
- tableName: resource.tableName
260
- };
431
+ const query = { ...body };
261
432
  validateAdvancedQuery(resource, query);
262
- const results = await repository.executeQueryBuilder(query);
263
- await res.status(200).json(results);
433
+ const results = await repo.executeQuery(query);
434
+ await writeSuccess(res, 200, results, envelope);
264
435
  }));
265
436
  }
266
437
  if (permissions.allowReadOne) {
267
438
  server.registerRoute('GET', `${basePath}/:id`, wrap(async (req, res) => {
268
- await authorizeRequest(req, resource, 'readOne', authStrategy);
439
+ const auth = await authorizeRequest(req, resource, 'readOne', authStrategy);
440
+ const repo = await resolveRepo(req, auth);
269
441
  const id = parseId(req.params['id']);
270
442
  const listOptions = parseListOptions(req.query, resource);
271
- const result = await repository.getOne(id, {
443
+ const result = await repo.getOne(id, {
272
444
  fields: listOptions.fields,
273
445
  include: listOptions.include
274
446
  });
275
447
  if (!result)
276
448
  throw new NotFoundError();
277
- await res.status(200).json(result);
449
+ await writeSuccess(res, 200, result, envelope);
278
450
  }));
279
451
  }
280
452
  if (permissions.allowUpdateOne) {
281
453
  server.registerRoute('PATCH', `${basePath}/:id`, wrap(async (req, res) => {
282
- await authorizeRequest(req, resource, 'updateOne', authStrategy);
454
+ const auth = await authorizeRequest(req, resource, 'updateOne', authStrategy);
455
+ const repo = await resolveRepo(req, auth);
283
456
  const id = parseId(req.params['id']);
284
457
  const body = filterWritableFields(resource, req.body);
285
- const result = await repository.updateOne(id, body);
458
+ const result = await repo.updateOne(id, body);
286
459
  if (!result)
287
460
  throw new NotFoundError();
288
- await res.status(200).json(result);
461
+ await writeSuccess(res, 200, result, envelope);
289
462
  }));
290
463
  }
291
464
  if (permissions.allowUpdateMany) {
292
465
  server.registerRoute('PATCH', basePath, wrap(async (req, res) => {
293
- await authorizeRequest(req, resource, 'updateMany', authStrategy);
294
- if (!repository.updateMany)
466
+ const auth = await authorizeRequest(req, resource, 'updateMany', authStrategy);
467
+ const repo = await resolveRepo(req, auth);
468
+ if (!repo.updateMany)
295
469
  throw new NotImplementedError('This resource does not support updateMany.');
296
470
  const { update, ...queryBody } = (req.body ?? {});
297
471
  const filteredUpdate = filterWritableFields(resource, (update ?? {}));
298
472
  if (!Object.keys(filteredUpdate).length)
299
473
  throw new UnprocessableEntityError('updateMany requires at least one writable field in the update payload.');
300
- const query = {
301
- ...queryBody,
302
- tableName: resource.tableName
303
- };
474
+ const query = { ...queryBody };
304
475
  validateAdvancedQuery(resource, query);
305
476
  if (!query.where?.length)
306
477
  throw new UnprocessableEntityError('updateMany requires at least one WHERE filter to prevent unintended bulk updates.');
307
- const result = await repository.updateMany(query, filteredUpdate);
308
- await res.status(200).json(result);
478
+ const result = await repo.updateMany(query, filteredUpdate);
479
+ await writeSuccess(res, 200, result, envelope);
309
480
  }));
310
481
  }
311
482
  if (permissions.allowUpsertOne) {
312
483
  server.registerRoute('PUT', `${basePath}/:id`, wrap(async (req, res) => {
313
- await authorizeRequest(req, resource, 'upsertOne', authStrategy);
314
- if (!repository.upsertOne)
484
+ const auth = await authorizeRequest(req, resource, 'upsertOne', authStrategy);
485
+ const repo = await resolveRepo(req, auth);
486
+ if (!repo.upsertOne)
315
487
  throw new NotImplementedError('This resource does not support upsert.');
316
488
  const id = parseId(req.params['id']);
317
489
  const body = filterWritableFields(resource, req.body);
318
- const result = await repository.upsertOne(id, body);
319
- await res.status(200).json(result);
490
+ const result = await repo.upsertOne(id, body);
491
+ await writeSuccess(res, 200, result, envelope);
320
492
  }));
321
493
  }
322
494
  if (permissions.allowDeleteOne) {
323
495
  server.registerRoute('DELETE', `${basePath}/:id`, wrap(async (req, res) => {
324
- await authorizeRequest(req, resource, 'deleteOne', authStrategy);
496
+ const auth = await authorizeRequest(req, resource, 'deleteOne', authStrategy);
497
+ const repo = await resolveRepo(req, auth);
325
498
  const id = parseId(req.params['id']);
326
- const deleted = await repository.deleteOne(id);
499
+ const deleted = await repo.deleteOne(id);
327
500
  if (!deleted)
328
501
  throw new NotFoundError();
329
- await res.status(200).json({ deleted: true });
502
+ await writeSuccess(res, 200, { deleted: true }, envelope);
330
503
  }));
331
504
  }
332
505
  if (permissions.allowDeleteMany) {
333
506
  server.registerRoute('DELETE', basePath, wrap(async (req, res) => {
334
- await authorizeRequest(req, resource, 'deleteMany', authStrategy);
335
- if (!repository.deleteMany)
507
+ const auth = await authorizeRequest(req, resource, 'deleteMany', authStrategy);
508
+ const repo = await resolveRepo(req, auth);
509
+ if (!repo.deleteMany)
336
510
  throw new NotImplementedError('This resource does not support deleteMany.');
337
511
  const body = (req.body ?? {});
338
- const query = {
339
- ...body,
340
- tableName: resource.tableName
341
- };
512
+ const query = { ...body };
342
513
  validateAdvancedQuery(resource, query);
343
514
  if (!query.where?.length)
344
515
  throw new UnprocessableEntityError('deleteMany requires at least one WHERE filter to prevent unintended bulk deletes.');
345
- const result = await repository.deleteMany(query);
346
- await res.status(200).json(result);
516
+ const result = await repo.deleteMany(query);
517
+ await writeSuccess(res, 200, result, envelope);
347
518
  }));
348
519
  }
349
520
  // 405 fallbacks — only registered when at least one method exists for the path
@@ -378,14 +549,4 @@ export function registerCrudApi(server, resources, options = {}) {
378
549
  });
379
550
  }
380
551
  });
381
- server.registerRoute('POST', previewPath, wrap(async (req, res) => {
382
- await authStrategy.authenticate(req);
383
- const query = req.body;
384
- assertSafePreviewIdentifiers(query);
385
- await res.status(200).json({
386
- count: QueryBuilder.buildCountQuery(query),
387
- select: QueryBuilder.buildSelectQuery(query)
388
- });
389
- }));
390
552
  }
391
- //# sourceMappingURL=crudRouter.js.map
@@ -1,13 +1,13 @@
1
- import type { ListOptions, ResourceDefinition } from '../core/types.js';
1
+ import { type ListOptions, type ResourceDefinition } from '../core/types.js';
2
2
  /**
3
3
  * Parses and validates the raw query-string from a GET request into typed {@link ListOptions}.
4
4
  *
5
5
  * Validates field names, sort fields, includes, and filter keys against the resource schema.
6
- * Applies `defaultLimit` and `maxLimit` from the resource definition when set.
6
+ * Applies the resource's `defaultLimit`/`maxLimit`, falling back to the framework page
7
+ * defaults so the result set is always bounded.
7
8
  *
8
9
  * @param query - The raw query-string object from the HTTP request.
9
10
  * @param resource - The resource definition used for field and relation validation.
10
11
  * @returns Typed list options ready to pass to `repository.getMany()`.
11
12
  */
12
13
  export declare function parseListOptions(query: Record<string, unknown>, resource: ResourceDefinition): ListOptions;
13
- //# sourceMappingURL=queryString.d.ts.map
@@ -1,5 +1,6 @@
1
1
  import { SqlOrder } from '../enums/SqlOrder.js';
2
2
  import { UnprocessableEntityError } from '../errors/UnprocessableEntityError.js';
3
+ import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT } from '../core/types.js';
3
4
  import { isValidInt32, validateFields, validateIncludes, validateQueryString, validateSelectableFields, validateSortableFields } from '../core/validation.js';
4
5
  /**
5
6
  * Parses and validates a single integer query-string value.
@@ -34,7 +35,8 @@ function parseCsv(value) {
34
35
  * Parses and validates the raw query-string from a GET request into typed {@link ListOptions}.
35
36
  *
36
37
  * Validates field names, sort fields, includes, and filter keys against the resource schema.
37
- * Applies `defaultLimit` and `maxLimit` from the resource definition when set.
38
+ * Applies the resource's `defaultLimit`/`maxLimit`, falling back to the framework page
39
+ * defaults so the result set is always bounded.
38
40
  *
39
41
  * @param query - The raw query-string object from the HTTP request.
40
42
  * @param resource - The resource definition used for field and relation validation.
@@ -47,10 +49,18 @@ export function parseListOptions(query, resource) {
47
49
  let limit = parseInteger(query.limit, 'limit');
48
50
  const offset = parseInteger(query.offset, 'offset', 0);
49
51
  const order = parseCsv(query.order);
50
- if (!limit && resource.defaultLimit)
51
- limit = resource.defaultLimit;
52
- if (limit && resource.maxLimit && limit > resource.maxLimit)
53
- limit = resource.maxLimit;
52
+ // Page size is bounded by sensible defaults, but a resource can opt out with `0` (= no
53
+ // bound). `count` in the response always reflects the true total, so a capped page is never
54
+ // a silent drop. Precedence: an explicit `?limit=` wins; otherwise the resource/default
55
+ // `defaultLimit` applies; finally a non-zero `maxLimit` caps the result either way.
56
+ const cap = resource.maxLimit ?? MAX_PAGE_LIMIT;
57
+ if (limit === undefined) {
58
+ const fallback = resource.defaultLimit ?? DEFAULT_PAGE_LIMIT;
59
+ if (fallback !== 0)
60
+ limit = fallback;
61
+ }
62
+ if (cap !== 0 && (limit === undefined || limit > cap))
63
+ limit = cap;
54
64
  if (fields) {
55
65
  validateFields(resource, fields);
56
66
  validateSelectableFields(resource, fields);
@@ -66,7 +76,7 @@ export function parseListOptions(query, resource) {
66
76
  validateSortableFields(resource, orderFields);
67
77
  }
68
78
  const where = {};
69
- const fieldNames = new Set(resource.fields.map((field) => field.name));
79
+ const fieldNames = new Set((resource.fields ?? []).map((field) => field.name));
70
80
  Object.entries(query).forEach(([key, value]) => {
71
81
  if (!fieldNames.has(key))
72
82
  return;
@@ -86,4 +96,3 @@ export function parseListOptions(query, resource) {
86
96
  })
87
97
  };
88
98
  }
89
- //# sourceMappingURL=queryString.js.map