@edium/halifax 2.2.3 → 2.3.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 (39) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README_AUTH.md +2 -2
  3. package/README_AUTOCRUD.md +5 -4
  4. package/README_CACHE.md +6 -0
  5. package/README_CLASSES.md +13 -6
  6. package/README_INTERFACES.md +13 -11
  7. package/README_OPENAPI.md +1 -1
  8. package/README_REPO_ADAPTERS.md +10 -0
  9. package/dist/adapters/orm/drizzle/DrizzleAdapter.d.ts +5 -0
  10. package/dist/adapters/orm/drizzle/DrizzleAdapter.js +9 -1
  11. package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +1 -2
  12. package/dist/adapters/orm/prisma/PrismaAdapter.js +106 -34
  13. package/dist/adapters/orm/prisma/astToPrisma.js +6 -0
  14. package/dist/auth/strategies/JwtClaimsAuthStrategy.js +2 -8
  15. package/dist/auth/strategies/PassportStrategies.js +3 -9
  16. package/dist/auth/strategies/types.d.ts +7 -0
  17. package/dist/auth/strategies/types.js +13 -1
  18. package/dist/core/cache/CacheStore.d.ts +12 -0
  19. package/dist/core/cache/createCachingRepository.js +10 -1
  20. package/dist/core/cache/in-memory/InMemoryCacheStore.d.ts +14 -1
  21. package/dist/core/cache/in-memory/InMemoryCacheStore.js +29 -1
  22. package/dist/core/cache/redis/RedisCacheStore.d.ts +6 -0
  23. package/dist/core/cache/redis/RedisCacheStore.js +14 -0
  24. package/dist/core/cache/redis/RedisLikeClient.d.ts +2 -0
  25. package/dist/core/crudRouter.js +2 -20
  26. package/dist/core/fields.d.ts +11 -1
  27. package/dist/core/fields.js +19 -0
  28. package/dist/core/handlerUtils.d.ts +6 -0
  29. package/dist/core/handlerUtils.js +15 -11
  30. package/dist/core/handlers/create.js +3 -2
  31. package/dist/core/handlers/query.js +3 -5
  32. package/dist/core/handlers/readMany.js +3 -5
  33. package/dist/core/handlers/readOne.js +3 -6
  34. package/dist/core/handlers/updateMany.js +3 -4
  35. package/dist/core/queryString.d.ts +10 -0
  36. package/dist/core/queryString.js +23 -0
  37. package/dist/core/validation.js +5 -11
  38. package/dist/openapi/specGenerator.js +19 -19
  39. package/package.json +2 -2
@@ -1,4 +1,5 @@
1
1
  import { AuthenticationError } from '../../errors/AuthenticationError.js';
2
+ import { checkRequiredPermissions } from './types.js';
2
3
  /**
3
4
  * Extracts `userId`, `roles`, `permissions`, and `claims` from a raw Passport user payload.
4
5
  * @param user - The raw user object returned by Passport (typically a decoded JWT payload).
@@ -17,13 +18,6 @@ function defaultMapUser(user) {
17
18
  ctx.userId = userId;
18
19
  return ctx;
19
20
  }
20
- function checkPermissions(auth, requiredPermissions) {
21
- if (!requiredPermissions.length)
22
- return true;
23
- const permissions = new Set(auth.permissions ?? []);
24
- const roles = new Set(auth.roles ?? []);
25
- return requiredPermissions.every((p) => permissions.has(p) || roles.has(p));
26
- }
27
21
  /** Delegates authentication to a caller-provided Passport authenticate wrapper. */
28
22
  export class PassportAuthStrategy {
29
23
  authenticateWithPassport;
@@ -85,7 +79,7 @@ export class PassportJwtStrategy {
85
79
  * @returns `true` when all required permissions are satisfied, `false` otherwise.
86
80
  */
87
81
  authorize(params) {
88
- return checkPermissions(params.auth, params.requiredPermissions);
82
+ return checkRequiredPermissions(params.auth, params.requiredPermissions);
89
83
  }
90
84
  openApiScheme() {
91
85
  return { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' };
@@ -129,7 +123,7 @@ export class PassportSessionStrategy {
129
123
  * @returns `true` when all required permissions are satisfied, `false` otherwise.
130
124
  */
131
125
  authorize(params) {
132
- return checkPermissions(params.auth, params.requiredPermissions);
126
+ return checkRequiredPermissions(params.auth, params.requiredPermissions);
133
127
  }
134
128
  openApiScheme() {
135
129
  return {
@@ -45,6 +45,13 @@ export type SecurityScheme = {
45
45
  scheme: 'basic';
46
46
  description?: string;
47
47
  };
48
+ /**
49
+ * Checks whether an auth context satisfies `requiredPermissions`.
50
+ * Semantics: **any single match** in `auth.permissions` OR `auth.roles` grants access
51
+ * (i.e. the list is an OR — "user must have at least one of these"). This mirrors
52
+ * the documented behaviour of `FieldDefinition.readRoles` / `writeRoles`.
53
+ */
54
+ export declare function checkRequiredPermissions(auth: AuthContext, requiredPermissions: string[]): boolean;
48
55
  /** Contract for pluggable authentication and authorisation strategies. */
49
56
  export interface AuthStrategy {
50
57
  /**
@@ -1 +1,13 @@
1
- export {};
1
+ /**
2
+ * Checks whether an auth context satisfies `requiredPermissions`.
3
+ * Semantics: **any single match** in `auth.permissions` OR `auth.roles` grants access
4
+ * (i.e. the list is an OR — "user must have at least one of these"). This mirrors
5
+ * the documented behaviour of `FieldDefinition.readRoles` / `writeRoles`.
6
+ */
7
+ export function checkRequiredPermissions(auth, requiredPermissions) {
8
+ if (!requiredPermissions.length)
9
+ return true;
10
+ const permissions = new Set(auth.permissions ?? []);
11
+ const roles = new Set(auth.roles ?? []);
12
+ return requiredPermissions.some((p) => permissions.has(p) || roles.has(p));
13
+ }
@@ -22,4 +22,16 @@ export interface CacheStore {
22
22
  * @param key - Cache key to remove.
23
23
  */
24
24
  delete(key: string): Promise<void> | void;
25
+ /**
26
+ * Atomically increment an integer counter and return the new value.
27
+ * When the key does not exist, it is initialised to `0` before incrementing.
28
+ *
29
+ * Implementing this method is **strongly recommended** for multi-process stores
30
+ * (e.g. Redis) — without it, `createCachingRepository` falls back to a non-atomic
31
+ * read-then-write version-bump that can lose invalidations under concurrent writes.
32
+ *
33
+ * Single-process stores (`InMemoryCacheStore`) can implement this synchronously and
34
+ * are immune to the race regardless, but should still implement it for correctness.
35
+ */
36
+ increment?(key: string): Promise<number> | number;
25
37
  }
@@ -34,7 +34,16 @@ export function createCachingRepository(repo, options) {
34
34
  return typeof v === 'number' ? v : 0;
35
35
  };
36
36
  const bumpVersion = async () => {
37
- await store.set(versionKey, (await readVersion()) + 1);
37
+ // Use store.increment when available (atomic on Redis via INCR; race-free on
38
+ // InMemoryCacheStore because Node.js is single-threaded). Fall back to a non-atomic
39
+ // read-then-write only when the store does not expose increment — acceptable for
40
+ // custom single-process stores where concurrent writes cannot interleave.
41
+ if (store.increment) {
42
+ await store.increment(versionKey);
43
+ }
44
+ else {
45
+ await store.set(versionKey, (await readVersion()) + 1);
46
+ }
38
47
  };
39
48
  const keyFor = async (op, payload) => `${namespace}:v${await readVersion()}:${op}:${stableStringify(payload)}`;
40
49
  const cachedRead = async (op, payload, run) => {
@@ -5,15 +5,28 @@ import type { CacheStore } from '../CacheStore.js';
5
5
  * Suitable for single-process deployments and tests. For multi-instance deployments,
6
6
  * inject a shared store (e.g. `RedisCacheStore`) instead so cache and invalidation are
7
7
  * consistent across processes.
8
+ *
9
+ * Expired entries are lazily evicted on reads. To prevent unbounded accumulation under
10
+ * write-heavy workloads, a sweep of all expired entries runs automatically every
11
+ * `sweepEvery` writes (default: 200). Adjust via the constructor option.
8
12
  */
9
13
  export declare class InMemoryCacheStore implements CacheStore {
10
14
  private readonly now;
11
15
  private readonly map;
16
+ private setCount;
17
+ private readonly sweepEvery;
12
18
  /**
13
19
  * @param now - Clock function (ms epoch). Injectable for tests; defaults to `Date.now`.
20
+ * @param sweepEvery - Run a full expired-entry sweep every N `set` calls. Defaults to 200.
14
21
  */
15
- constructor(now?: () => number);
22
+ constructor(now?: () => number, sweepEvery?: number);
16
23
  get(key: string): unknown;
17
24
  set(key: string, value: unknown, ttlSeconds?: number): void;
18
25
  delete(key: string): void;
26
+ /**
27
+ * Atomically increments an integer counter and returns the new value.
28
+ * Because Node.js is single-threaded this is race-free without a lock.
29
+ */
30
+ increment(key: string): number;
31
+ private purgeExpired;
19
32
  }
@@ -4,15 +4,23 @@
4
4
  * Suitable for single-process deployments and tests. For multi-instance deployments,
5
5
  * inject a shared store (e.g. `RedisCacheStore`) instead so cache and invalidation are
6
6
  * consistent across processes.
7
+ *
8
+ * Expired entries are lazily evicted on reads. To prevent unbounded accumulation under
9
+ * write-heavy workloads, a sweep of all expired entries runs automatically every
10
+ * `sweepEvery` writes (default: 200). Adjust via the constructor option.
7
11
  */
8
12
  export class InMemoryCacheStore {
9
13
  now;
10
14
  map = new Map();
15
+ setCount = 0;
16
+ sweepEvery;
11
17
  /**
12
18
  * @param now - Clock function (ms epoch). Injectable for tests; defaults to `Date.now`.
19
+ * @param sweepEvery - Run a full expired-entry sweep every N `set` calls. Defaults to 200.
13
20
  */
14
- constructor(now = () => Date.now()) {
21
+ constructor(now = () => Date.now(), sweepEvery = 200) {
15
22
  this.now = now;
23
+ this.sweepEvery = sweepEvery;
16
24
  }
17
25
  get(key) {
18
26
  const entry = this.map.get(key);
@@ -27,8 +35,28 @@ export class InMemoryCacheStore {
27
35
  set(key, value, ttlSeconds) {
28
36
  const expiresAt = ttlSeconds && ttlSeconds > 0 ? this.now() + ttlSeconds * 1000 : null;
29
37
  this.map.set(key, { value, expiresAt });
38
+ if (++this.setCount % this.sweepEvery === 0)
39
+ this.purgeExpired();
30
40
  }
31
41
  delete(key) {
32
42
  this.map.delete(key);
33
43
  }
44
+ /**
45
+ * Atomically increments an integer counter and returns the new value.
46
+ * Because Node.js is single-threaded this is race-free without a lock.
47
+ */
48
+ increment(key) {
49
+ const entry = this.map.get(key);
50
+ const current = typeof entry?.value === 'number' ? entry.value : 0;
51
+ const next = current + 1;
52
+ this.map.set(key, { value: next, expiresAt: null });
53
+ return next;
54
+ }
55
+ purgeExpired() {
56
+ const now = this.now();
57
+ for (const [key, entry] of this.map) {
58
+ if (entry.expiresAt !== null && entry.expiresAt <= now)
59
+ this.map.delete(key);
60
+ }
61
+ }
34
62
  }
@@ -25,4 +25,10 @@ export declare class RedisCacheStore implements CacheStore {
25
25
  get(key: string): Promise<unknown>;
26
26
  set(key: string, value: unknown, ttlSeconds?: number): Promise<void>;
27
27
  delete(key: string): Promise<void>;
28
+ /**
29
+ * Atomically increments the integer stored at `key` (using Redis `INCR`) and returns
30
+ * the new value. Falls back to a non-atomic get-then-set when the underlying client
31
+ * does not expose `incr` — this should not occur with standard Redis clients.
32
+ */
33
+ increment(key: string): Promise<number>;
28
34
  }
@@ -39,4 +39,18 @@ export class RedisCacheStore {
39
39
  async delete(key) {
40
40
  await this.client.del(this.prefix + key);
41
41
  }
42
+ /**
43
+ * Atomically increments the integer stored at `key` (using Redis `INCR`) and returns
44
+ * the new value. Falls back to a non-atomic get-then-set when the underlying client
45
+ * does not expose `incr` — this should not occur with standard Redis clients.
46
+ */
47
+ async increment(key) {
48
+ if (this.client.incr) {
49
+ return await this.client.incr(this.prefix + key);
50
+ }
51
+ const raw = await this.client.get(this.prefix + key);
52
+ const next = (raw !== null ? parseInt(raw, 10) : 0) + 1;
53
+ await this.client.set(this.prefix + key, String(next));
54
+ return next;
55
+ }
42
56
  }
@@ -9,4 +9,6 @@ export interface RedisLikeClient {
9
9
  EX?: number;
10
10
  }): Promise<unknown>;
11
11
  del(key: string): Promise<unknown>;
12
+ /** Atomic integer increment — maps to Redis `INCR`. Optional but strongly recommended. */
13
+ incr?(key: string): Promise<number>;
12
14
  }
@@ -6,7 +6,7 @@ import { ServerError } from '../errors/ServerError.js';
6
6
  import { AuthorizationError } from '../errors/AuthorizationError.js';
7
7
  import { MethodNotAllowedError } from '../errors/MethodNotAllowedError.js';
8
8
  import { normalizeError, sendError, wantsCacheBust } from '../core/handlerUtils.js';
9
- import { mergeFieldDefinitions } from '../core/fields.js';
9
+ import { mergeFieldDefinitions, mergeRelationDefinitions, normalizeEnvelope } from '../core/fields.js';
10
10
  import { registerCreate } from '../core/handlers/create.js';
11
11
  import { registerReadMany } from '../core/handlers/readMany.js';
12
12
  import { registerReadOne } from '../core/handlers/readOne.js';
@@ -53,17 +53,6 @@ function resolveFields(resource, idField) {
53
53
  ...(field.writeRoles?.length ? { writeRoles: field.writeRoles } : {})
54
54
  }));
55
55
  }
56
- /**
57
- * Merges the repository's relation schema with the resource's own relations, by name.
58
- */
59
- function resolveRelations(resource) {
60
- const byName = new Map();
61
- for (const relation of resource.repository?.relations ?? [])
62
- byName.set(relation.name, relation);
63
- for (const relation of resource.relations ?? [])
64
- byName.set(relation.name, relation);
65
- return [...byName.values()];
66
- }
67
56
  /**
68
57
  * Produces a fully-resolved resource: `name` filled in, and `fields`/`relations` resolved
69
58
  * from the repository schema + the resource's own entries. Every downstream stage operates
@@ -75,16 +64,9 @@ function normalizeResource(resource) {
75
64
  ...resource,
76
65
  name: resource.name ?? deriveResourceName(resource.routePrefix),
77
66
  fields: resolveFields(resource, idField),
78
- relations: resolveRelations(resource)
67
+ relations: mergeRelationDefinitions(resource)
79
68
  };
80
69
  }
81
- /**
82
- * Resolves the effective envelope key. A non-empty string enables wrapping; `null`, `undefined`,
83
- * and `''` all mean "no envelope".
84
- */
85
- function normalizeEnvelope(value) {
86
- return typeof value === 'string' && value.length > 0 ? value : null;
87
- }
88
70
  /**
89
71
  * Determines the column a resource is tenant-scoped on, with this precedence:
90
72
  * explicit `resource.tenant` (or `false` to opt out) → auto-detect the API's default
@@ -1,4 +1,4 @@
1
- import type { FieldDefinition, ResourceDefinition } from '../core/types.js';
1
+ import type { FieldDefinition, RelationDefinition, ResourceDefinition } from '../core/types.js';
2
2
  /**
3
3
  * Merges a resource's field schema: repository fields are the base, and
4
4
  * `resource.fields` entries are applied as sparse overrides (by name).
@@ -6,3 +6,13 @@ import type { FieldDefinition, ResourceDefinition } from '../core/types.js';
6
6
  * the flags they care about on top of this.
7
7
  */
8
8
  export declare function mergeFieldDefinitions(resource: ResourceDefinition): FieldDefinition[];
9
+ /**
10
+ * Merges a resource's relation schema: repository relations are the base, and
11
+ * `resource.relations` entries are applied as sparse overrides (by name).
12
+ */
13
+ export declare function mergeRelationDefinitions(resource: ResourceDefinition): RelationDefinition[];
14
+ /**
15
+ * Resolves the effective envelope key. A non-empty string enables wrapping;
16
+ * `null`, `undefined`, and `''` all mean "no envelope".
17
+ */
18
+ export declare function normalizeEnvelope(value: string | null | undefined): string | null;
@@ -12,3 +12,22 @@ export function mergeFieldDefinitions(resource) {
12
12
  byName.set(f.name, { ...byName.get(f.name), ...f });
13
13
  return [...byName.values()];
14
14
  }
15
+ /**
16
+ * Merges a resource's relation schema: repository relations are the base, and
17
+ * `resource.relations` entries are applied as sparse overrides (by name).
18
+ */
19
+ export function mergeRelationDefinitions(resource) {
20
+ const byName = new Map();
21
+ for (const r of resource.repository?.relations ?? [])
22
+ byName.set(r.name, r);
23
+ for (const r of resource.relations ?? [])
24
+ byName.set(r.name, r);
25
+ return [...byName.values()];
26
+ }
27
+ /**
28
+ * Resolves the effective envelope key. A non-empty string enables wrapping;
29
+ * `null`, `undefined`, and `''` all mean "no envelope".
30
+ */
31
+ export function normalizeEnvelope(value) {
32
+ return typeof value === 'string' && value.length > 0 ? value : null;
33
+ }
@@ -41,6 +41,12 @@ export declare function filterWritableFields(resource: ResourceDefinition, data:
41
41
  * Fast-path returns the record unchanged when no fields carry read restrictions.
42
42
  */
43
43
  export declare function filterReadableFields(resource: ResourceDefinition, record: Record<string, unknown>, auth?: AuthContext): Record<string, unknown>;
44
+ /**
45
+ * Returns a reusable filter function that strips read-restricted fields.
46
+ * Build this once per request (outside a `.map()` loop) so the fieldMap and
47
+ * userRoles Set are not reconstructed for every record in a bulk response.
48
+ */
49
+ export declare function makeReadableFieldFilter(resource: ResourceDefinition, auth?: AuthContext): (record: Record<string, unknown>) => Record<string, unknown>;
44
50
  /**
45
51
  * Runs the auth strategy for `action` and throws {@link AuthorizationError} when not allowed.
46
52
  */
@@ -1,3 +1,4 @@
1
+ import { checkRequiredPermissions } from '../auth/strategies/types.js';
1
2
  import { HttpError } from '../errors/HttpError.js';
2
3
  import { NotAcceptableError } from '../errors/NotAcceptableError.js';
3
4
  import { UnprocessableEntityError } from '../errors/UnprocessableEntityError.js';
@@ -63,9 +64,9 @@ export function writeSuccess(res, status, body, envelope) {
63
64
  */
64
65
  export function parseId(raw) {
65
66
  validateId(raw);
66
- if (typeof raw === 'string' && (isValidUuid(raw) || isValidObjectId(raw)))
67
+ if (isValidUuid(raw) || isValidObjectId(raw))
67
68
  return raw;
68
- return typeof raw === 'string' ? parseInt(raw, 10) : raw;
69
+ return parseInt(raw, 10);
69
70
  }
70
71
  /**
71
72
  * Strips non-writable fields from a request body and rejects unknown fields with a 422.
@@ -96,12 +97,20 @@ export function filterWritableFields(resource, data, auth) {
96
97
  * Fast-path returns the record unchanged when no fields carry read restrictions.
97
98
  */
98
99
  export function filterReadableFields(resource, record, auth) {
100
+ return makeReadableFieldFilter(resource, auth)(record);
101
+ }
102
+ /**
103
+ * Returns a reusable filter function that strips read-restricted fields.
104
+ * Build this once per request (outside a `.map()` loop) so the fieldMap and
105
+ * userRoles Set are not reconstructed for every record in a bulk response.
106
+ */
107
+ export function makeReadableFieldFilter(resource, auth) {
99
108
  const fields = resource.fields ?? [];
100
109
  if (!fields.some((f) => (f.readRoles?.length ?? 0) > 0))
101
- return record;
110
+ return (r) => r;
102
111
  const fieldMap = new Map(fields.map((f) => [f.name, f]));
103
112
  const userRoles = new Set([...(auth?.roles ?? []), ...(auth?.permissions ?? [])]);
104
- return Object.fromEntries(Object.entries(record).filter(([key]) => {
113
+ return (record) => Object.fromEntries(Object.entries(record).filter(([key]) => {
105
114
  const field = fieldMap.get(key);
106
115
  if (!field?.readRoles?.length)
107
116
  return true;
@@ -126,13 +135,8 @@ export async function authorizeRequest(req, resource, action, authStrategy) {
126
135
  throw new AuthorizationError();
127
136
  return auth;
128
137
  }
129
- if (requiredPermissions.length) {
130
- const permissions = new Set(auth.permissions ?? []);
131
- const roles = new Set(auth.roles ?? []);
132
- const allowed = requiredPermissions.some((permission) => permissions.has(permission) || roles.has(permission));
133
- if (!allowed)
134
- throw new AuthorizationError();
135
- }
138
+ if (!checkRequiredPermissions(auth, requiredPermissions))
139
+ throw new AuthorizationError();
136
140
  return auth;
137
141
  }
138
142
  /**
@@ -1,4 +1,4 @@
1
- import { applyHook, authorizeRequest, filterReadableFields, filterWritableFields, getHeaderValue, wrap, writeSuccess } from '../../core/handlerUtils.js';
1
+ import { applyHook, authorizeRequest, filterReadableFields, filterWritableFields, getHeaderValue, makeReadableFieldFilter, wrap, writeSuccess } from '../../core/handlerUtils.js';
2
2
  export function registerCreate(server, basePath, ctx) {
3
3
  const { resource, authStrategy, envelope, hooks, resolveRepo } = ctx;
4
4
  server.registerRoute('POST', basePath, wrap(async (req, res) => {
@@ -21,6 +21,7 @@ export function registerCreate(server, basePath, ctx) {
21
21
  const results = hooks?.afterCreate
22
22
  ? await Promise.all(rawResults.map((r) => applyHook(hooks.afterCreate, r, hookCtx)))
23
23
  : rawResults;
24
- await writeSuccess(res, 201, results.map((r) => filterReadableFields(resource, r, auth)), envelope);
24
+ const filterRecord = makeReadableFieldFilter(resource, auth);
25
+ await writeSuccess(res, 201, results.map(filterRecord), envelope);
25
26
  }));
26
27
  }
@@ -1,6 +1,6 @@
1
1
  import { validateAdvancedQuery } from '../../core/validation.js';
2
2
  import { NotImplementedError } from '../../errors/NotImplementedError.js';
3
- import { applyHook, authorizeRequest, filterReadableFields, wrap, writeSuccess } from '../../core/handlerUtils.js';
3
+ import { applyHook, authorizeRequest, makeReadableFieldFilter, wrap, writeSuccess } from '../../core/handlerUtils.js';
4
4
  export function registerQuery(server, basePath, queryBuilderPath, ctx) {
5
5
  const { resource, authStrategy, envelope, hooks, resolveRepo } = ctx;
6
6
  server.registerRoute('POST', `${basePath}/${queryBuilderPath}`, wrap(async (req, res) => {
@@ -15,9 +15,7 @@ export function registerQuery(server, basePath, queryBuilderPath, ctx) {
15
15
  validateAdvancedQuery(resource, query);
16
16
  const rawResult = await repo.executeQuery(query);
17
17
  const result = await applyHook(hooks?.afterQuery, rawResult, hookCtx);
18
- await writeSuccess(res, 200, {
19
- ...result,
20
- results: result.results.map((r) => filterReadableFields(resource, r, auth))
21
- }, envelope);
18
+ const filterRecord = makeReadableFieldFilter(resource, auth);
19
+ await writeSuccess(res, 200, { ...result, results: result.results.map(filterRecord) }, envelope);
22
20
  }));
23
21
  }
@@ -1,5 +1,5 @@
1
1
  import { parseListOptions } from '../../core/queryString.js';
2
- import { applyHook, authorizeRequest, filterReadableFields, wrap, writeSuccess } from '../../core/handlerUtils.js';
2
+ import { applyHook, authorizeRequest, makeReadableFieldFilter, wrap, writeSuccess } from '../../core/handlerUtils.js';
3
3
  export function registerReadMany(server, basePath, ctx) {
4
4
  const { resource, authStrategy, envelope, hooks, resolveRepo } = ctx;
5
5
  server.registerRoute('GET', basePath, wrap(async (req, res) => {
@@ -10,9 +10,7 @@ export function registerReadMany(server, basePath, ctx) {
10
10
  const listOptions = await applyHook(hooks?.beforeReadMany, parsedOptions, hookCtx);
11
11
  const rawResult = await repo.getMany(listOptions);
12
12
  const result = await applyHook(hooks?.afterReadMany, rawResult, hookCtx);
13
- await writeSuccess(res, 200, {
14
- ...result,
15
- results: result.results.map((r) => filterReadableFields(resource, r, auth))
16
- }, envelope);
13
+ const filterRecord = makeReadableFieldFilter(resource, auth);
14
+ await writeSuccess(res, 200, { ...result, results: result.results.map(filterRecord) }, envelope);
17
15
  }));
18
16
  }
@@ -1,4 +1,4 @@
1
- import { parseListOptions } from '../../core/queryString.js';
1
+ import { parseGetOneOptions } from '../../core/queryString.js';
2
2
  import { NotFoundError } from '../../errors/NotFoundError.js';
3
3
  import { applyHook, authorizeRequest, filterReadableFields, parseId, wrap, writeSuccess } from '../../core/handlerUtils.js';
4
4
  export function registerReadOne(server, basePath, ctx) {
@@ -10,11 +10,8 @@ export function registerReadOne(server, basePath, ctx) {
10
10
  const hookCtx = { auth, resource, req };
11
11
  if (hooks?.beforeReadOne)
12
12
  await hooks.beforeReadOne(id, hookCtx);
13
- const listOptions = parseListOptions(req.query, resource);
14
- const rawResult = await repo.getOne(id, {
15
- fields: listOptions.fields,
16
- include: listOptions.include
17
- });
13
+ const { fields, include } = parseGetOneOptions(req.query, resource);
14
+ const rawResult = await repo.getOne(id, { fields, include });
18
15
  if (!rawResult)
19
16
  throw new NotFoundError();
20
17
  const result = await applyHook(hooks?.afterReadOne, rawResult, hookCtx);
@@ -1,7 +1,7 @@
1
1
  import { validateAdvancedQuery } from '../../core/validation.js';
2
2
  import { NotImplementedError } from '../../errors/NotImplementedError.js';
3
3
  import { UnprocessableEntityError } from '../../errors/UnprocessableEntityError.js';
4
- import { applyHook, authorizeRequest, filterReadableFields, filterWritableFields, wrap, writeSuccess } from '../../core/handlerUtils.js';
4
+ import { applyHook, authorizeRequest, filterWritableFields, makeReadableFieldFilter, wrap, writeSuccess } from '../../core/handlerUtils.js';
5
5
  export function registerUpdateMany(server, basePath, ctx) {
6
6
  const { resource, authStrategy, envelope, hooks, resolveRepo } = ctx;
7
7
  server.registerRoute('PATCH', basePath, wrap(async (req, res) => {
@@ -22,12 +22,11 @@ export function registerUpdateMany(server, basePath, ctx) {
22
22
  await hooks.beforeUpdateMany(query, filteredUpdate, hookCtx);
23
23
  const rawResult = await repo.updateMany(query, filteredUpdate);
24
24
  const result = await applyHook(hooks?.afterUpdateMany, rawResult, hookCtx);
25
+ const filterRecord = makeReadableFieldFilter(resource, auth);
25
26
  await writeSuccess(res, 200, {
26
27
  ...result,
27
28
  ...(result.results
28
- ? {
29
- results: result.results.map((r) => filterReadableFields(resource, r, auth))
30
- }
29
+ ? { results: result.results.map((r) => filterRecord(r)) }
31
30
  : {})
32
31
  }, envelope);
33
32
  }));
@@ -1,4 +1,14 @@
1
1
  import { type ListOptions, type ResourceDefinition } from '../core/types.js';
2
+ /**
3
+ * Parses and validates the query-string for a single-record GET (`GET /resource/:id`).
4
+ * Only `?fields=` and `?include=` are meaningful for that endpoint — this avoids the wasted
5
+ * work and silent-discard behaviour of calling the full `parseListOptions` on a by-ID route.
6
+ *
7
+ * @param query - The raw query-string object from the HTTP request.
8
+ * @param resource - The resource definition used for field and relation validation.
9
+ * @returns Typed projection options ready to pass to `repository.getOne()`.
10
+ */
11
+ export declare function parseGetOneOptions(query: Record<string, unknown>, resource: ResourceDefinition): Pick<ListOptions, 'fields' | 'include'>;
2
12
  /**
3
13
  * Parses and validates the raw query-string from a GET request into typed {@link ListOptions}.
4
14
  *
@@ -31,6 +31,29 @@ function parseCsv(value) {
31
31
  .map((item) => item.trim())
32
32
  .filter(Boolean);
33
33
  }
34
+ /**
35
+ * Parses and validates the query-string for a single-record GET (`GET /resource/:id`).
36
+ * Only `?fields=` and `?include=` are meaningful for that endpoint — this avoids the wasted
37
+ * work and silent-discard behaviour of calling the full `parseListOptions` on a by-ID route.
38
+ *
39
+ * @param query - The raw query-string object from the HTTP request.
40
+ * @param resource - The resource definition used for field and relation validation.
41
+ * @returns Typed projection options ready to pass to `repository.getOne()`.
42
+ */
43
+ export function parseGetOneOptions(query, resource) {
44
+ const fields = parseCsv(query.fields);
45
+ const include = parseCsv(query.include);
46
+ if (fields) {
47
+ validateFields(resource, fields);
48
+ validateSelectableFields(resource, fields);
49
+ }
50
+ if (include)
51
+ validateIncludes(resource, include);
52
+ if (fields && include) {
53
+ throw new UnprocessableEntityError('Cannot use both ?fields= and ?include= in the same request.');
54
+ }
55
+ return { fields, include };
56
+ }
34
57
  /**
35
58
  * Parses and validates the raw query-string from a GET request into typed {@link ListOptions}.
36
59
  *
@@ -58,9 +58,7 @@ export function validateId(value) {
58
58
  * @returns Array of field name strings.
59
59
  */
60
60
  export function getFieldNames(resource) {
61
- return (resource.fields ?? []).map((field) => {
62
- return field.name;
63
- });
61
+ return (resource.fields ?? []).map((f) => f.name);
64
62
  }
65
63
  /**
66
64
  * Throws {@link UnprocessableEntityError} when any of `fields` are not defined on the resource.
@@ -82,10 +80,8 @@ export function validateFields(resource, fields = []) {
82
80
  * @param fields - Field names to check for selectability.
83
81
  */
84
82
  export function validateSelectableFields(resource, fields) {
85
- const nonSelectable = fields.filter((name) => {
86
- const field = resource.fields?.find((f) => f.name === name);
87
- return field?.selectable === false;
88
- });
83
+ const fieldMap = new Map((resource.fields ?? []).map((f) => [f.name, f]));
84
+ const nonSelectable = fields.filter((name) => fieldMap.get(name)?.selectable === false);
89
85
  if (nonSelectable.length) {
90
86
  throw new UnprocessableEntityError(`Field(s) not selectable: ${nonSelectable.join(', ')}.`);
91
87
  }
@@ -96,10 +92,8 @@ export function validateSelectableFields(resource, fields) {
96
92
  * @param fields - Field names to check for sortability.
97
93
  */
98
94
  export function validateSortableFields(resource, fields) {
99
- const nonSortable = fields.filter((name) => {
100
- const field = resource.fields?.find((f) => f.name === name);
101
- return field?.sortable === false;
102
- });
95
+ const fieldMap = new Map((resource.fields ?? []).map((f) => [f.name, f]));
96
+ const nonSortable = fields.filter((name) => fieldMap.get(name)?.sortable === false);
103
97
  if (nonSortable.length) {
104
98
  throw new UnprocessableEntityError(`Field(s) not sortable: ${nonSortable.join(', ')}.`);
105
99
  }