@edium/halifax 2.2.2 → 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 (44) hide show
  1. package/CHANGELOG.md +161 -1
  2. package/README.md +15 -15
  3. package/README_AUTH.md +2 -2
  4. package/README_AUTOCRUD.md +5 -4
  5. package/README_CACHE.md +6 -0
  6. package/README_CLASSES.md +13 -6
  7. package/README_INTERFACES.md +13 -11
  8. package/README_OPENAPI.md +1 -1
  9. package/README_REPO_ADAPTERS.md +10 -0
  10. package/dist/adapters/orm/drizzle/DrizzleAdapter.d.ts +5 -0
  11. package/dist/adapters/orm/drizzle/DrizzleAdapter.js +57 -14
  12. package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +1 -2
  13. package/dist/adapters/orm/prisma/PrismaAdapter.js +149 -39
  14. package/dist/adapters/orm/prisma/astToPrisma.js +6 -0
  15. package/dist/auth/strategies/JwtClaimsAuthStrategy.js +2 -8
  16. package/dist/auth/strategies/PassportStrategies.js +3 -9
  17. package/dist/auth/strategies/types.d.ts +7 -0
  18. package/dist/auth/strategies/types.js +13 -1
  19. package/dist/core/cache/CacheStore.d.ts +12 -0
  20. package/dist/core/cache/createCachingRepository.js +10 -1
  21. package/dist/core/cache/in-memory/InMemoryCacheStore.d.ts +14 -1
  22. package/dist/core/cache/in-memory/InMemoryCacheStore.js +29 -1
  23. package/dist/core/cache/redis/RedisCacheStore.d.ts +6 -0
  24. package/dist/core/cache/redis/RedisCacheStore.js +14 -0
  25. package/dist/core/cache/redis/RedisLikeClient.d.ts +2 -0
  26. package/dist/core/crudRouter.js +2 -20
  27. package/dist/core/fields.d.ts +11 -1
  28. package/dist/core/fields.js +19 -0
  29. package/dist/core/handlerUtils.d.ts +6 -0
  30. package/dist/core/handlerUtils.js +16 -11
  31. package/dist/core/handlers/create.js +3 -2
  32. package/dist/core/handlers/query.js +3 -5
  33. package/dist/core/handlers/readMany.js +3 -5
  34. package/dist/core/handlers/readOne.js +3 -6
  35. package/dist/core/handlers/updateMany.js +3 -4
  36. package/dist/core/queryString.d.ts +10 -0
  37. package/dist/core/queryString.js +23 -0
  38. package/dist/core/validation.js +5 -11
  39. package/dist/errors/ConflictError.d.ts +5 -0
  40. package/dist/errors/ConflictError.js +8 -0
  41. package/dist/index.d.ts +1 -0
  42. package/dist/index.js +1 -0
  43. package/dist/openapi/specGenerator.js +24 -19
  44. package/package.json +2 -2
@@ -1,7 +1,9 @@
1
- import { NotImplementedError } from '../../../errors/NotImplementedError.js';
1
+ import { ConflictError } from '../../../errors/ConflictError.js';
2
2
  import { NotFoundError } from '../../../errors/NotFoundError.js';
3
+ import { NotImplementedError } from '../../../errors/NotImplementedError.js';
3
4
  import { ServerError } from '../../../errors/ServerError.js';
4
- import { astToPrismaWhere, astToPrismaOrderBy } from './astToPrisma.js';
5
+ import { astToPrismaOrderBy, astToPrismaWhere } from './astToPrisma.js';
6
+ import { toInclude, toOrderBy, toSelect } from './helpers.js';
5
7
  /** Returns true for Prisma's P2025 "record not found" error. */
6
8
  function isNotFoundError(error) {
7
9
  return (typeof error === 'object' &&
@@ -9,7 +11,26 @@ function isNotFoundError(error) {
9
11
  'code' in error &&
10
12
  error.code === 'P2025');
11
13
  }
12
- import { toSelect, toInclude, toOrderBy } from './helpers.js';
14
+ /** Returns true for Prisma's P2002 unique constraint violation. */
15
+ function isDuplicateError(error) {
16
+ return (typeof error === 'object' &&
17
+ error !== null &&
18
+ 'code' in error &&
19
+ error.code === 'P2002');
20
+ }
21
+ /**
22
+ * Returns true for SQL Server's "IDENTITY_INSERT is set to OFF" error (code 544).
23
+ * MSSQL IDENTITY columns reject any explicit-value INSERT via the driver adapter rather
24
+ * than surfacing a P2002 duplicate — so this must be caught separately.
25
+ */
26
+ function isIdentityInsertError(error) {
27
+ if (typeof error !== 'object' || error === null)
28
+ return false;
29
+ const cause = error.cause;
30
+ return (typeof cause === 'object' &&
31
+ cause !== null &&
32
+ cause.code === 544);
33
+ }
13
34
  function prismaTypeToOpenApi(prismaType) {
14
35
  switch (prismaType) {
15
36
  case 'Int':
@@ -247,7 +268,14 @@ export class PrismaAdapter {
247
268
  * @throws ServerError if the Prisma delegate does not support the create method.
248
269
  */
249
270
  async createOne(data) {
250
- return (await this.delegate.create({ data: this.stampTenant(data) }));
271
+ try {
272
+ return (await this.delegate.create({ data: this.stampTenant(data) }));
273
+ }
274
+ catch (error) {
275
+ if (isDuplicateError(error))
276
+ throw new ConflictError();
277
+ throw error;
278
+ }
251
279
  }
252
280
  /**
253
281
  * Creates multiple records in the database using the provided array of data objects.
@@ -260,7 +288,14 @@ export class PrismaAdapter {
260
288
  if (!this.delegate.createMany || this.returnCreated) {
261
289
  return await Promise.all(data.map((item) => this.createOne(item)));
262
290
  }
263
- await this.delegate.createMany({ data: data.map((item) => this.stampTenant(item)) });
291
+ try {
292
+ await this.delegate.createMany({ data: data.map((item) => this.stampTenant(item)) });
293
+ }
294
+ catch (error) {
295
+ if (isDuplicateError(error))
296
+ throw new ConflictError();
297
+ throw error;
298
+ }
264
299
  return [];
265
300
  }
266
301
  /**
@@ -271,19 +306,24 @@ export class PrismaAdapter {
271
306
  * @throws ServerError if the Prisma delegate does not support the update method.
272
307
  */
273
308
  async updateOne(id, data) {
274
- // When scoped, confirm the row belongs to the caller's tenant before touching it,
275
- // and strip the tenant field from the payload so the row can't be moved tenants.
276
309
  if (this.scope) {
277
- if (!this.delegate.findFirst) {
278
- throw new ServerError('Prisma delegate does not support findFirst (required for tenant scoping).');
310
+ const scopedWhere = this.scopedWhere({ [this.idField]: id });
311
+ // Preferred path: delegate.updateMany lets us do a single atomic statement whose
312
+ // WHERE enforces the tenant boundary, eliminating the TOCTOU window.
313
+ if (this.delegate.updateMany && this.delegate.findFirst) {
314
+ const { count } = await this.delegate.updateMany({
315
+ where: scopedWhere,
316
+ data: this.stripTenant(data)
317
+ });
318
+ if (count === 0)
319
+ return null;
320
+ return (await this.delegate.findFirst({ where: scopedWhere }));
279
321
  }
280
- const owned = await this.delegate.findFirst({
281
- where: this.scopedWhere({ [this.idField]: id }),
282
- select: { [this.idField]: true }
283
- });
284
- if (!owned)
285
- return null;
286
- data = this.stripTenant(data);
322
+ // updateMany is unavailable — we cannot perform a single atomic scoped update.
323
+ // A two-step findFirst + unscoped update would introduce a TOCTOU window where a
324
+ // record could be transferred to another tenant between the check and the write.
325
+ // Refuse rather than risk a cross-tenant modification.
326
+ throw new ServerError('Prisma delegate does not support updateMany (required for safe tenant-scoped updateOne).');
287
327
  }
288
328
  try {
289
329
  return (await this.delegate.update({ where: { [this.idField]: id }, data }));
@@ -291,6 +331,8 @@ export class PrismaAdapter {
291
331
  catch (error) {
292
332
  if (isNotFoundError(error))
293
333
  return null;
334
+ if (isDuplicateError(error))
335
+ throw new ConflictError();
294
336
  throw error;
295
337
  }
296
338
  }
@@ -331,33 +373,107 @@ export class PrismaAdapter {
331
373
  * @throws ServerError if the Prisma delegate does not support the required methods for upserting records.
332
374
  */
333
375
  async upsertOne(id, data) {
334
- if (!this.delegate.upsert) {
335
- throw new NotImplementedError('Prisma delegate does not support upsert.');
336
- }
337
- // When scoped, an upsert keyed on a unique id could otherwise overwrite a row owned
338
- // by another tenant. Reject that case (hidden as "not found"), stamp the tenant on
339
- // create, and forbid reassigning the tenant on update.
376
+ // Scoped upsert: do NOT use delegate.upsert with a bare id where-clause — Prisma's upsert
377
+ // would execute its `update` branch against any matching record regardless of tenant, giving
378
+ // a cross-tenant write if a race places another tenant's row at that id. Instead we decompose
379
+ // into a scoped findFirst + a scoped updateMany (create on miss) so the tenant constraint is
380
+ // enforced at every statement.
340
381
  if (this.scope) {
341
382
  if (!this.delegate.findFirst) {
342
383
  throw new ServerError('Prisma delegate does not support findFirst (required for tenant scoping).');
343
384
  }
385
+ const scopedWhere = this.scopedWhere({ [this.idField]: id });
344
386
  const existing = (await this.delegate.findFirst({
345
- where: { [this.idField]: id }
387
+ where: scopedWhere
346
388
  }));
389
+ // Defense-in-depth: even though scopedWhere already filters by tenant, verify the
390
+ // returned record actually belongs to this tenant before treating it as owned.
347
391
  if (existing && existing[this.scope.field] !== this.scope.value) {
348
392
  throw new NotFoundError();
349
393
  }
394
+ if (existing) {
395
+ // Record exists for this tenant — update it atomically via updateMany(scopedWhere)
396
+ // so the tenant constraint is enforced in the same SQL statement as the write.
397
+ if (this.delegate.updateMany) {
398
+ const { count } = await this.delegate.updateMany({
399
+ where: scopedWhere,
400
+ data: this.stripTenant(data)
401
+ });
402
+ if (count === 0) {
403
+ // Deleted in the tiny window between findFirst and updateMany — treat as a
404
+ // fresh create so the caller gets a record back (consistent with upsert semantics).
405
+ try {
406
+ return (await this.delegate.create({
407
+ data: this.stampTenant({ ...data, [this.idField]: id })
408
+ }));
409
+ }
410
+ catch (error) {
411
+ if (isDuplicateError(error))
412
+ throw new ConflictError();
413
+ if (isIdentityInsertError(error)) {
414
+ const anyMatch = await this.delegate.findFirst({ where: { [this.idField]: id } });
415
+ if (anyMatch)
416
+ throw new ConflictError();
417
+ return (await this.delegate.create({ data: this.stampTenant(data) }));
418
+ }
419
+ throw error;
420
+ }
421
+ }
422
+ return (await this.delegate.findFirst({ where: scopedWhere }));
423
+ }
424
+ // Fallback when updateMany is unavailable (non-standard delegate). The update is still
425
+ // scoped via the earlier findFirst; the TOCTOU window here is only closeable with a
426
+ // transaction, which we cannot guarantee across providers.
427
+ try {
428
+ return (await this.delegate.update({
429
+ where: { [this.idField]: id },
430
+ data: this.stripTenant(data)
431
+ }));
432
+ }
433
+ catch (error) {
434
+ if (isNotFoundError(error))
435
+ throw new NotFoundError();
436
+ if (isDuplicateError(error))
437
+ throw new ConflictError();
438
+ throw error;
439
+ }
440
+ }
441
+ // Record does not exist for this tenant — create it with the tenant stamped.
442
+ try {
443
+ return (await this.delegate.create({
444
+ data: this.stampTenant({ ...data, [this.idField]: id })
445
+ }));
446
+ }
447
+ catch (error) {
448
+ if (isDuplicateError(error))
449
+ throw new ConflictError();
450
+ if (isIdentityInsertError(error)) {
451
+ // MSSQL IDENTITY columns reject any explicit-ID insert. Probe to distinguish a
452
+ // cross-tenant ID collision (another tenant owns this ID → ConflictError) from a
453
+ // genuinely new row (let the DB assign the ID instead).
454
+ const anyMatch = await this.delegate.findFirst({ where: { [this.idField]: id } });
455
+ if (anyMatch)
456
+ throw new ConflictError();
457
+ return (await this.delegate.create({ data: this.stampTenant(data) }));
458
+ }
459
+ throw error;
460
+ }
461
+ }
462
+ if (!this.delegate.upsert) {
463
+ throw new NotImplementedError('Prisma delegate does not support upsert.');
464
+ }
465
+ try {
350
466
  return (await this.delegate.upsert({
351
467
  where: { [this.idField]: id },
352
- create: this.stampTenant(data),
353
- update: this.stripTenant(data)
468
+ create: data,
469
+ update: data
354
470
  }));
355
471
  }
356
- return (await this.delegate.upsert({
357
- where: { [this.idField]: id },
358
- create: data,
359
- update: data
360
- }));
472
+ catch (error) {
473
+ if (isDuplicateError(error))
474
+ throw new ConflictError();
475
+ throw error;
476
+ }
361
477
  }
362
478
  /**
363
479
  * Deletes a single record identified by its ID. If the record does not exist, it returns false.
@@ -378,15 +494,9 @@ export class PrismaAdapter {
378
494
  });
379
495
  return (result?.count ?? 0) > 0;
380
496
  }
381
- if (!this.delegate.findFirst) {
382
- throw new ServerError('Prisma delegate does not support deleteMany or findFirst (required for tenant scoping).');
383
- }
384
- const owned = await this.delegate.findFirst({
385
- where: this.scopedWhere({ [this.idField]: id }),
386
- select: { [this.idField]: true }
387
- });
388
- if (!owned)
389
- return false;
497
+ // deleteMany is unavailable — we cannot do an atomic scoped delete. A two-step
498
+ // findFirst + unscoped delete would introduce a TOCTOU cross-tenant deletion risk.
499
+ throw new ServerError('Prisma delegate does not support deleteMany (required for safe tenant-scoped deleteOne).');
390
500
  }
391
501
  try {
392
502
  await this.delegate.delete({ where: { [this.idField]: id } });
@@ -3,6 +3,12 @@ import { SqlComparison, SqlOperator, SqlOrder } from '@edium/halifax-types';
3
3
  * Splits a `LIKE` pattern into a Prisma string operator based on its `%` wildcards.
4
4
  * `%x%` → `contains`, `x%` → `startsWith`, `%x` → `endsWith`, and a wildcard-free value
5
5
  * collapses to `equals` (matching SQL `LIKE 'x'` semantics).
6
+ *
7
+ * **Known limitation:** interior wildcards (`'foo%bar'`) cannot be expressed with Prisma's
8
+ * string operators — they fall through to `equals`, which is an exact match, not a
9
+ * wildcard match. Prisma has no `matches`/`glob` operator without raw SQL. Use `CONTAINS`,
10
+ * `STARTS WITH`, or `ENDS WITH` comparisons instead of `LIKE` when possible.
11
+ *
6
12
  * @param value - The raw LIKE pattern.
7
13
  * @returns A Prisma string-filter object.
8
14
  */
@@ -1,4 +1,5 @@
1
1
  import { AuthenticationError } from '../../errors/AuthenticationError.js';
2
+ import { checkRequiredPermissions } from './types.js';
2
3
  /** Authenticates via a Bearer JWT and authorises using roles/permissions embedded in its claims. */
3
4
  export class JwtClaimsAuthStrategy {
4
5
  verifyToken;
@@ -31,14 +32,7 @@ export class JwtClaimsAuthStrategy {
31
32
  * @returns `true` when all required permissions are satisfied, `false` otherwise.
32
33
  */
33
34
  authorize(params) {
34
- if (!params.requiredPermissions.length) {
35
- return true;
36
- }
37
- const permissions = new Set(params.auth.permissions ?? []);
38
- const roles = new Set(params.auth.roles ?? []);
39
- return params.requiredPermissions.every((permission) => {
40
- return permissions.has(permission) || roles.has(permission);
41
- });
35
+ return checkRequiredPermissions(params.auth, params.requiredPermissions);
42
36
  }
43
37
  openApiScheme() {
44
38
  return { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' };
@@ -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
  */