@edium/halifax 1.0.0 → 2.0.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 +83 -0
  2. package/README.md +72 -50
  3. package/README_AUTOCRUD.md +61 -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 +65 -8
  41. package/dist/core/crudRouter.js +231 -95
  42. package/dist/core/queryString.d.ts +3 -3
  43. package/dist/core/queryString.js +16 -7
  44. package/dist/core/types.d.ts +141 -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.
@@ -124,6 +175,7 @@ async function sendError(error, res) {
124
175
  * @param resource - The resource being accessed (used to look up required permissions).
125
176
  * @param action - The CRUD action being performed.
126
177
  * @param authStrategy - The active auth strategy.
178
+ * @returns The resolved {@link AuthContext} (so callers can derive the tenant scope from it).
127
179
  */
128
180
  async function authorizeRequest(req, resource, action, authStrategy) {
129
181
  const auth = await authStrategy.authenticate(req);
@@ -138,7 +190,7 @@ async function authorizeRequest(req, resource, action, authStrategy) {
138
190
  });
139
191
  if (!allowed)
140
192
  throw new AuthorizationError();
141
- return;
193
+ return auth;
142
194
  }
143
195
  if (requiredPermissions.length) {
144
196
  const permissions = new Set(auth.permissions ?? []);
@@ -147,7 +199,28 @@ async function authorizeRequest(req, resource, action, authStrategy) {
147
199
  if (!allowed)
148
200
  throw new AuthorizationError();
149
201
  }
202
+ return auth;
203
+ }
204
+ /**
205
+ * Determines the column a resource is tenant-scoped on, applying this precedence:
206
+ * explicit `resource.tenant` (or `false` to opt out) → auto-detect the API's default
207
+ * tenant field when the resource actually has it → otherwise unscoped (global).
208
+ * @param resource - The resource being inspected.
209
+ * @param tenant - The API-wide tenant options, or `undefined` when tenancy is off.
210
+ * @returns The tenant field name, or `null` when the resource is not scoped.
211
+ */
212
+ function effectiveTenantField(resource, tenant) {
213
+ if (!tenant)
214
+ return null;
215
+ if (resource.tenant === false)
216
+ return null;
217
+ if (resource.tenant && resource.tenant.field)
218
+ return resource.tenant.field;
219
+ const fallback = tenant.field ?? 'tenantId';
220
+ return (resource.fields ?? []).some((f) => f.name === fallback) ? fallback : null;
150
221
  }
222
+ /** Matches a safe SQL identifier — tenant fields are interpolated into SQL on bulk paths. */
223
+ const safeIdentifier = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
151
224
  /**
152
225
  * Reads a single header value by name (case-insensitive).
153
226
  * @param req - The incoming HTTP request.
@@ -159,6 +232,22 @@ function getHeaderValue(req, name) {
159
232
  const value = Array.isArray(raw) ? raw[0] : raw;
160
233
  return typeof value === 'string' ? value : undefined;
161
234
  }
235
+ /**
236
+ * Decides whether the request asks to bypass (bust) the cache. For the standard
237
+ * `Cache-Control` header this means a `no-cache`/`no-store` directive; for any custom
238
+ * bust header, mere presence (with a non-empty value) triggers a refresh.
239
+ * @param req - The incoming HTTP request.
240
+ * @param header - The configured bust header name.
241
+ * @returns `true` when the cache should be force-refreshed for this request.
242
+ */
243
+ function wantsCacheBust(req, header) {
244
+ const value = getHeaderValue(req, header);
245
+ if (!value)
246
+ return false;
247
+ if (header.toLowerCase() === 'cache-control')
248
+ return /no-cache|no-store/i.test(value);
249
+ return true;
250
+ }
162
251
  /**
163
252
  * Throws {@link UnsupportedMediaTypeError} when a body-carrying request uses a non-JSON Content-Type.
164
253
  * @param req - The incoming HTTP request to check.
@@ -209,66 +298,124 @@ function wrap(handler) {
209
298
  * Registers all CRUD routes for every resource on the given HTTP server.
210
299
  *
211
300
  * Routes are controlled by `resource.permissions` merged with {@link defaultCrudPermissions}.
212
- * A global query-builder preview endpoint is also registered at `previewQueryBuilderPath`.
213
301
  *
214
302
  * @param server - The HTTP server adapter to register routes on (e.g. {@link ExpressHttpServer}).
215
303
  * @param resources - Resource definitions to wire up as CRUD endpoints.
216
- * @param options - Auth strategy, query-builder path overrides, and preview path overrides.
304
+ * @param options - Auth strategy and query-builder path overrides.
217
305
  */
218
306
  export function registerCrudApi(server, resources, options = {}) {
219
307
  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;
308
+ const queryBuilderPath = options.queryBuilderPath ?? 'query';
309
+ // One shared cache store for the whole API (created lazily only if any resource caches),
310
+ // so version-based invalidation is consistent across requests and resources.
311
+ const cacheStore = options.cache?.store ?? new InMemoryCacheStore();
312
+ const bustHeader = options.cache?.bustHeader ?? 'cache-control';
313
+ resources.forEach((rawResource) => {
314
+ const repository = rawResource.repository;
224
315
  if (!repository)
225
- throw new ServerError(`Resource '${resource.name}' does not define a repository.`);
316
+ throw new ServerError(`Resource '${rawResource.name ?? rawResource.routePrefix}' does not define a repository.`);
317
+ // Resolve name + field/relation schema once, here, so every downstream stage (validation,
318
+ // write-filtering, tenant auto-detection, cache namespace) works off a single source of
319
+ // truth with all defaults already applied.
320
+ const resource = normalizeResource(rawResource);
321
+ // Resolve tenancy once at registration and fail closed on misconfiguration, so a
322
+ // scoped resource can never be silently served unscoped at request time.
323
+ const tenantField = effectiveTenantField(resource, options.tenant);
324
+ if (tenantField) {
325
+ if (!safeIdentifier.test(tenantField))
326
+ throw new ServerError(`Resource '${resource.name}' has an unsafe tenant field name '${tenantField}'.`);
327
+ if (!repository.withScope)
328
+ throw new ServerError(`Resource '${resource.name}' is tenant-scoped on '${tenantField}' but its repository ` +
329
+ `does not implement withScope(). Refusing to serve it unscoped.`);
330
+ }
331
+ // Effective cache TTL: an explicit `cache: false` disables; otherwise per-resource config
332
+ // wins over the API-wide default. `0` means "never expire" (so `undefined` = no caching).
333
+ const cacheTtl = resource.cache === false
334
+ ? undefined
335
+ : (resource.cache?.ttlSeconds ?? options.cache?.ttlSeconds);
336
+ const cachingEnabled = cacheTtl !== undefined;
337
+ /**
338
+ * Wraps a repository in the read-through cache when caching is enabled for this resource.
339
+ * The namespace embeds the resource name and tenant key, so one tenant can never read
340
+ * another's cached rows. `bust` (from the cache-bust header) force-refreshes this request.
341
+ * @param repo - The (possibly tenant-scoped) repository for this request.
342
+ * @param scopeKey - Stable key for the current tenant scope (or `'global'`).
343
+ * @param bust - Whether the caller requested a cache-busting refresh.
344
+ * @returns The repository, cache-wrapped when caching is enabled.
345
+ */
346
+ const withCache = (repo, scopeKey, bust) => cachingEnabled
347
+ ? createCachingRepository(repo, {
348
+ store: cacheStore,
349
+ ttlSeconds: cacheTtl,
350
+ namespace: `${resource.name}:${scopeKey}`,
351
+ bust
352
+ })
353
+ : repo;
354
+ /**
355
+ * Returns the repository to use for this request: the tenant-scoped clone when the
356
+ * resource is scoped, or the bare repository otherwise — wrapped in the read-through
357
+ * cache when enabled. Throws 403 in strict mode (the default) when a scoped resource
358
+ * cannot resolve a tenant for the caller.
359
+ */
360
+ const resolveRepo = async (req, auth) => {
361
+ const bust = cachingEnabled && wantsCacheBust(req, bustHeader);
362
+ if (!tenantField || !options.tenant)
363
+ return withCache(repository, 'global', bust);
364
+ const value = await options.tenant.resolveId({ auth, req, resource });
365
+ if (value === undefined || value === null || value === '') {
366
+ if (options.tenant.strict !== false)
367
+ throw new AuthorizationError('No tenant is associated with this request.');
368
+ return withCache(repository, 'global', bust);
369
+ }
370
+ return withCache(repository.withScope({ field: tenantField, value }), String(value), bust);
371
+ };
226
372
  const permissions = { ...defaultCrudPermissions, ...resource.permissions };
227
373
  const basePath = `/${resource.routePrefix}`;
228
374
  if (permissions.allowCreate) {
229
375
  server.registerRoute('POST', basePath, wrap(async (req, res) => {
230
- await authorizeRequest(req, resource, 'create', authStrategy);
376
+ const auth = await authorizeRequest(req, resource, 'create', authStrategy);
377
+ const repo = await resolveRepo(req, auth);
231
378
  const idempotencyKey = getHeaderValue(req, 'idempotency-key');
232
379
  const createOptions = idempotencyKey ? { idempotencyKey } : undefined;
233
380
  const items = (Array.isArray(req.body) ? req.body : [req.body]).map((item) => filterWritableFields(resource, item));
234
381
  if (items.length === 1) {
235
- const result = await repository.createOne(items[0], createOptions);
382
+ const result = await repo.createOne(items[0], createOptions);
236
383
  await res.status(201).json(result);
237
384
  return;
238
385
  }
239
- const results = await repository.createMany(items, createOptions);
386
+ const results = await repo.createMany(items, createOptions);
240
387
  await res.status(201).json(results);
241
388
  }));
242
389
  }
243
390
  if (permissions.allowReadMany) {
244
391
  server.registerRoute('GET', basePath, wrap(async (req, res) => {
245
- await authorizeRequest(req, resource, 'readMany', authStrategy);
392
+ const auth = await authorizeRequest(req, resource, 'readMany', authStrategy);
393
+ const repo = await resolveRepo(req, auth);
246
394
  const listOptions = parseListOptions(req.query, resource);
247
- const results = await repository.getMany(listOptions);
395
+ const results = await repo.getMany(listOptions);
248
396
  await res.status(200).json(results);
249
397
  }));
250
398
  }
251
399
  if (permissions.allowReadManyWithQueryBuilder) {
252
400
  server.registerRoute('POST', `${basePath}/${queryBuilderPath}`, wrap(async (req, res) => {
253
- await authorizeRequest(req, resource, 'readManyWithQueryBuilder', authStrategy);
254
- if (!repository.executeQueryBuilder)
401
+ const auth = await authorizeRequest(req, resource, 'readManyWithQueryBuilder', authStrategy);
402
+ const repo = await resolveRepo(req, auth);
403
+ if (!repo.executeQuery)
255
404
  throw new NotImplementedError('This resource does not support the query builder.');
256
405
  const body = (req.body ?? {});
257
- const query = {
258
- ...body,
259
- tableName: resource.tableName
260
- };
406
+ const query = { ...body };
261
407
  validateAdvancedQuery(resource, query);
262
- const results = await repository.executeQueryBuilder(query);
408
+ const results = await repo.executeQuery(query);
263
409
  await res.status(200).json(results);
264
410
  }));
265
411
  }
266
412
  if (permissions.allowReadOne) {
267
413
  server.registerRoute('GET', `${basePath}/:id`, wrap(async (req, res) => {
268
- await authorizeRequest(req, resource, 'readOne', authStrategy);
414
+ const auth = await authorizeRequest(req, resource, 'readOne', authStrategy);
415
+ const repo = await resolveRepo(req, auth);
269
416
  const id = parseId(req.params['id']);
270
417
  const listOptions = parseListOptions(req.query, resource);
271
- const result = await repository.getOne(id, {
418
+ const result = await repo.getOne(id, {
272
419
  fields: listOptions.fields,
273
420
  include: listOptions.include
274
421
  });
@@ -279,10 +426,11 @@ export function registerCrudApi(server, resources, options = {}) {
279
426
  }
280
427
  if (permissions.allowUpdateOne) {
281
428
  server.registerRoute('PATCH', `${basePath}/:id`, wrap(async (req, res) => {
282
- await authorizeRequest(req, resource, 'updateOne', authStrategy);
429
+ const auth = await authorizeRequest(req, resource, 'updateOne', authStrategy);
430
+ const repo = await resolveRepo(req, auth);
283
431
  const id = parseId(req.params['id']);
284
432
  const body = filterWritableFields(resource, req.body);
285
- const result = await repository.updateOne(id, body);
433
+ const result = await repo.updateOne(id, body);
286
434
  if (!result)
287
435
  throw new NotFoundError();
288
436
  await res.status(200).json(result);
@@ -290,40 +438,40 @@ export function registerCrudApi(server, resources, options = {}) {
290
438
  }
291
439
  if (permissions.allowUpdateMany) {
292
440
  server.registerRoute('PATCH', basePath, wrap(async (req, res) => {
293
- await authorizeRequest(req, resource, 'updateMany', authStrategy);
294
- if (!repository.updateMany)
441
+ const auth = await authorizeRequest(req, resource, 'updateMany', authStrategy);
442
+ const repo = await resolveRepo(req, auth);
443
+ if (!repo.updateMany)
295
444
  throw new NotImplementedError('This resource does not support updateMany.');
296
445
  const { update, ...queryBody } = (req.body ?? {});
297
446
  const filteredUpdate = filterWritableFields(resource, (update ?? {}));
298
447
  if (!Object.keys(filteredUpdate).length)
299
448
  throw new UnprocessableEntityError('updateMany requires at least one writable field in the update payload.');
300
- const query = {
301
- ...queryBody,
302
- tableName: resource.tableName
303
- };
449
+ const query = { ...queryBody };
304
450
  validateAdvancedQuery(resource, query);
305
451
  if (!query.where?.length)
306
452
  throw new UnprocessableEntityError('updateMany requires at least one WHERE filter to prevent unintended bulk updates.');
307
- const result = await repository.updateMany(query, filteredUpdate);
453
+ const result = await repo.updateMany(query, filteredUpdate);
308
454
  await res.status(200).json(result);
309
455
  }));
310
456
  }
311
457
  if (permissions.allowUpsertOne) {
312
458
  server.registerRoute('PUT', `${basePath}/:id`, wrap(async (req, res) => {
313
- await authorizeRequest(req, resource, 'upsertOne', authStrategy);
314
- if (!repository.upsertOne)
459
+ const auth = await authorizeRequest(req, resource, 'upsertOne', authStrategy);
460
+ const repo = await resolveRepo(req, auth);
461
+ if (!repo.upsertOne)
315
462
  throw new NotImplementedError('This resource does not support upsert.');
316
463
  const id = parseId(req.params['id']);
317
464
  const body = filterWritableFields(resource, req.body);
318
- const result = await repository.upsertOne(id, body);
465
+ const result = await repo.upsertOne(id, body);
319
466
  await res.status(200).json(result);
320
467
  }));
321
468
  }
322
469
  if (permissions.allowDeleteOne) {
323
470
  server.registerRoute('DELETE', `${basePath}/:id`, wrap(async (req, res) => {
324
- await authorizeRequest(req, resource, 'deleteOne', authStrategy);
471
+ const auth = await authorizeRequest(req, resource, 'deleteOne', authStrategy);
472
+ const repo = await resolveRepo(req, auth);
325
473
  const id = parseId(req.params['id']);
326
- const deleted = await repository.deleteOne(id);
474
+ const deleted = await repo.deleteOne(id);
327
475
  if (!deleted)
328
476
  throw new NotFoundError();
329
477
  await res.status(200).json({ deleted: true });
@@ -331,18 +479,16 @@ export function registerCrudApi(server, resources, options = {}) {
331
479
  }
332
480
  if (permissions.allowDeleteMany) {
333
481
  server.registerRoute('DELETE', basePath, wrap(async (req, res) => {
334
- await authorizeRequest(req, resource, 'deleteMany', authStrategy);
335
- if (!repository.deleteMany)
482
+ const auth = await authorizeRequest(req, resource, 'deleteMany', authStrategy);
483
+ const repo = await resolveRepo(req, auth);
484
+ if (!repo.deleteMany)
336
485
  throw new NotImplementedError('This resource does not support deleteMany.');
337
486
  const body = (req.body ?? {});
338
- const query = {
339
- ...body,
340
- tableName: resource.tableName
341
- };
487
+ const query = { ...body };
342
488
  validateAdvancedQuery(resource, query);
343
489
  if (!query.where?.length)
344
490
  throw new UnprocessableEntityError('deleteMany requires at least one WHERE filter to prevent unintended bulk deletes.');
345
- const result = await repository.deleteMany(query);
491
+ const result = await repo.deleteMany(query);
346
492
  await res.status(200).json(result);
347
493
  }));
348
494
  }
@@ -378,14 +524,4 @@ export function registerCrudApi(server, resources, options = {}) {
378
524
  });
379
525
  }
380
526
  });
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
527
  }
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