@edium/halifax 1.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +72 -50
  3. package/README_AUTOCRUD.md +94 -19
  4. package/README_QUERYBUILDER.md +1 -1
  5. package/README_REPO_ADAPTERS.md +80 -11
  6. package/dist/adapters/http/ExpressAdapter.d.ts +34 -5
  7. package/dist/adapters/http/ExpressAdapter.js +20 -12
  8. package/dist/adapters/http/FastifyAdapter.d.ts +93 -0
  9. package/dist/adapters/http/FastifyAdapter.js +125 -0
  10. package/dist/adapters/http/HyperExpressAdapter.d.ts +82 -0
  11. package/dist/adapters/http/HyperExpressAdapter.js +128 -0
  12. package/dist/adapters/http/UltimateExpressAdapter.d.ts +84 -0
  13. package/dist/adapters/http/UltimateExpressAdapter.js +108 -0
  14. package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +89 -40
  15. package/dist/adapters/orm/prisma/PrismaAdapter.js +233 -71
  16. package/dist/adapters/orm/prisma/astToPrisma.d.ts +26 -0
  17. package/dist/adapters/orm/prisma/astToPrisma.js +140 -0
  18. package/dist/adapters/orm/prisma/createPrismaResources.d.ts +1 -2
  19. package/dist/adapters/orm/prisma/createPrismaResources.js +10 -6
  20. package/dist/adapters/orm/prisma/helpers.d.ts +0 -1
  21. package/dist/adapters/orm/prisma/helpers.js +0 -1
  22. package/dist/adapters/orm/prisma/index.d.ts +1 -2
  23. package/dist/adapters/orm/prisma/index.js +0 -1
  24. package/dist/adapters/orm/prisma/types.d.ts +14 -9
  25. package/dist/adapters/orm/prisma/types.js +0 -1
  26. package/dist/auth/AuthStrategy.d.ts +0 -9
  27. package/dist/auth/AuthStrategy.js +0 -7
  28. package/dist/core/cache/CacheStore.d.ts +25 -0
  29. package/dist/core/cache/CacheStore.js +1 -0
  30. package/dist/core/cache/createCachingRepository.d.ts +39 -0
  31. package/dist/core/cache/createCachingRepository.js +116 -0
  32. package/dist/core/cache/in-memory/InMemoryCacheStore.d.ts +19 -0
  33. package/dist/core/cache/in-memory/InMemoryCacheStore.js +34 -0
  34. package/dist/core/cache/index.d.ts +5 -0
  35. package/dist/core/cache/index.js +5 -0
  36. package/dist/core/cache/redis/RedisCacheStore.d.ts +28 -0
  37. package/dist/core/cache/redis/RedisCacheStore.js +42 -0
  38. package/dist/core/cache/redis/RedisLikeClient.d.ts +12 -0
  39. package/dist/core/cache/redis/RedisLikeClient.js +1 -0
  40. package/dist/core/crudRouter.d.ts +72 -8
  41. package/dist/core/crudRouter.js +266 -105
  42. package/dist/core/queryString.d.ts +3 -3
  43. package/dist/core/queryString.js +16 -7
  44. package/dist/core/types.d.ts +151 -31
  45. package/dist/core/types.js +13 -1
  46. package/dist/core/validation.d.ts +12 -4
  47. package/dist/core/validation.js +33 -13
  48. package/dist/enums/SqlComparison.d.ts +13 -3
  49. package/dist/enums/SqlComparison.js +12 -2
  50. package/dist/enums/SqlOperator.d.ts +0 -1
  51. package/dist/enums/SqlOperator.js +0 -1
  52. package/dist/enums/SqlOrder.d.ts +0 -1
  53. package/dist/enums/SqlOrder.js +0 -1
  54. package/dist/errors/AuthenticationError.d.ts +0 -1
  55. package/dist/errors/AuthenticationError.js +0 -1
  56. package/dist/errors/AuthorizationError.d.ts +0 -1
  57. package/dist/errors/AuthorizationError.js +0 -1
  58. package/dist/errors/BadRequestError.d.ts +0 -1
  59. package/dist/errors/BadRequestError.js +0 -1
  60. package/dist/errors/HttpError.d.ts +0 -1
  61. package/dist/errors/HttpError.js +0 -1
  62. package/dist/errors/MethodNotAllowedError.d.ts +0 -1
  63. package/dist/errors/MethodNotAllowedError.js +0 -1
  64. package/dist/errors/NotAcceptableError.d.ts +0 -1
  65. package/dist/errors/NotAcceptableError.js +0 -1
  66. package/dist/errors/NotFoundError.d.ts +0 -1
  67. package/dist/errors/NotFoundError.js +0 -1
  68. package/dist/errors/NotImplementedError.d.ts +0 -1
  69. package/dist/errors/NotImplementedError.js +0 -1
  70. package/dist/errors/ServerError.d.ts +0 -1
  71. package/dist/errors/ServerError.js +0 -1
  72. package/dist/errors/UnprocessableEntityError.d.ts +0 -1
  73. package/dist/errors/UnprocessableEntityError.js +0 -1
  74. package/dist/errors/UnsupportedMediaTypeError.d.ts +0 -1
  75. package/dist/errors/UnsupportedMediaTypeError.js +0 -1
  76. package/dist/index.d.ts +1 -3
  77. package/dist/index.js +1 -3
  78. package/dist/interfaces/IQueryFilter.d.ts +1 -2
  79. package/dist/interfaces/IQueryFilter.js +0 -1
  80. package/dist/interfaces/IQueryOptions.d.ts +9 -9
  81. package/dist/interfaces/IQueryOptions.js +0 -1
  82. package/dist/interfaces/ISort.d.ts +0 -1
  83. package/dist/interfaces/ISort.js +0 -1
  84. package/package.json +10 -8
  85. package/dist/adapters/http/ExpressAdapter.d.ts.map +0 -1
  86. package/dist/adapters/http/ExpressAdapter.js.map +0 -1
  87. package/dist/adapters/orm/prisma/PrismaAdapter.d.ts.map +0 -1
  88. package/dist/adapters/orm/prisma/PrismaAdapter.js.map +0 -1
  89. package/dist/adapters/orm/prisma/createPrismaResources.d.ts.map +0 -1
  90. package/dist/adapters/orm/prisma/createPrismaResources.js.map +0 -1
  91. package/dist/adapters/orm/prisma/helpers.d.ts.map +0 -1
  92. package/dist/adapters/orm/prisma/helpers.js.map +0 -1
  93. package/dist/adapters/orm/prisma/index.d.ts.map +0 -1
  94. package/dist/adapters/orm/prisma/index.js.map +0 -1
  95. package/dist/adapters/orm/prisma/types.d.ts.map +0 -1
  96. package/dist/adapters/orm/prisma/types.js.map +0 -1
  97. package/dist/auth/AuthStrategy.d.ts.map +0 -1
  98. package/dist/auth/AuthStrategy.js.map +0 -1
  99. package/dist/classes/QueryBuilder.d.ts +0 -33
  100. package/dist/classes/QueryBuilder.d.ts.map +0 -1
  101. package/dist/classes/QueryBuilder.js +0 -262
  102. package/dist/classes/QueryBuilder.js.map +0 -1
  103. package/dist/core/crudRouter.d.ts.map +0 -1
  104. package/dist/core/crudRouter.js.map +0 -1
  105. package/dist/core/queryString.d.ts.map +0 -1
  106. package/dist/core/queryString.js.map +0 -1
  107. package/dist/core/types.d.ts.map +0 -1
  108. package/dist/core/types.js.map +0 -1
  109. package/dist/core/validation.d.ts.map +0 -1
  110. package/dist/core/validation.js.map +0 -1
  111. package/dist/enums/SqlComparison.d.ts.map +0 -1
  112. package/dist/enums/SqlComparison.js.map +0 -1
  113. package/dist/enums/SqlOperator.d.ts.map +0 -1
  114. package/dist/enums/SqlOperator.js.map +0 -1
  115. package/dist/enums/SqlOrder.d.ts.map +0 -1
  116. package/dist/enums/SqlOrder.js.map +0 -1
  117. package/dist/errors/AuthenticationError.d.ts.map +0 -1
  118. package/dist/errors/AuthenticationError.js.map +0 -1
  119. package/dist/errors/AuthorizationError.d.ts.map +0 -1
  120. package/dist/errors/AuthorizationError.js.map +0 -1
  121. package/dist/errors/BadRequestError.d.ts.map +0 -1
  122. package/dist/errors/BadRequestError.js.map +0 -1
  123. package/dist/errors/HttpError.d.ts.map +0 -1
  124. package/dist/errors/HttpError.js.map +0 -1
  125. package/dist/errors/MethodNotAllowedError.d.ts.map +0 -1
  126. package/dist/errors/MethodNotAllowedError.js.map +0 -1
  127. package/dist/errors/NotAcceptableError.d.ts.map +0 -1
  128. package/dist/errors/NotAcceptableError.js.map +0 -1
  129. package/dist/errors/NotFoundError.d.ts.map +0 -1
  130. package/dist/errors/NotFoundError.js.map +0 -1
  131. package/dist/errors/NotImplementedError.d.ts.map +0 -1
  132. package/dist/errors/NotImplementedError.js.map +0 -1
  133. package/dist/errors/ServerError.d.ts.map +0 -1
  134. package/dist/errors/ServerError.js.map +0 -1
  135. package/dist/errors/UnprocessableEntityError.d.ts.map +0 -1
  136. package/dist/errors/UnprocessableEntityError.js.map +0 -1
  137. package/dist/errors/UnsupportedMediaTypeError.d.ts.map +0 -1
  138. package/dist/errors/UnsupportedMediaTypeError.js.map +0 -1
  139. package/dist/index.d.ts.map +0 -1
  140. package/dist/index.js.map +0 -1
  141. package/dist/interfaces/IParamQuery.d.ts +0 -8
  142. package/dist/interfaces/IParamQuery.d.ts.map +0 -1
  143. package/dist/interfaces/IParamQuery.js +0 -2
  144. package/dist/interfaces/IParamQuery.js.map +0 -1
  145. package/dist/interfaces/IQueryFilter.d.ts.map +0 -1
  146. package/dist/interfaces/IQueryFilter.js.map +0 -1
  147. package/dist/interfaces/IQueryOptions.d.ts.map +0 -1
  148. package/dist/interfaces/IQueryOptions.js.map +0 -1
  149. package/dist/interfaces/ISort.d.ts.map +0 -1
  150. package/dist/interfaces/ISort.js.map +0 -1
@@ -3,7 +3,7 @@ import { toRoutePrefix } from './helpers.js';
3
3
  /**
4
4
  * Creates resource definitions for Prisma models based on the provided schema and options.
5
5
  * @param prismaClient An instance of the Prisma client to be used for database operations.
6
- * @param schema An array of model definitions, where each model includes its name, optional database name, and fields.
6
+ * @param schema An array of model definitions, where each model includes its name and fields.
7
7
  * @param options An optional configuration object that allows customization of the generated resources, including model-specific options, default limits, and permissions.
8
8
  * @returns An array of resource definitions that can be used to set up API endpoints or other data access layers based on the Prisma models.
9
9
  */
@@ -13,13 +13,10 @@ export function createPrismaResources(prismaClient, schema, options = {}) {
13
13
  .filter((model) => !options.models?.[model.name]?.exclude)
14
14
  .map((model) => {
15
15
  const modelOpts = options.models?.[model.name] ?? {};
16
- const tableName = modelOpts.tableName ?? model.dbName ?? model.name;
17
16
  const routePrefix = modelOpts.routePrefix ?? toRoutePrefix(model.name);
18
17
  const delegateKey = model.name.charAt(0).toLowerCase() + model.name.slice(1);
19
18
  const adapter = new PrismaAdapter({
20
19
  delegate: client[delegateKey],
21
- client: client,
22
- tableName,
23
20
  ...(options.idField !== undefined && { idField: options.idField }),
24
21
  ...(options.returnCreated !== undefined && { returnCreated: options.returnCreated }),
25
22
  model
@@ -27,13 +24,21 @@ export function createPrismaResources(prismaClient, schema, options = {}) {
27
24
  const resource = {
28
25
  name: model.name,
29
26
  routePrefix,
30
- tableName,
31
27
  fields: adapter.fields,
32
28
  repository: adapter,
33
29
  permissions: { ...options.permissions, ...modelOpts.permissions }
34
30
  };
35
31
  if (adapter.relations?.length)
36
32
  resource.relations = adapter.relations;
33
+ // Tenant scoping: an explicit per-model setting wins (including `false` to opt out);
34
+ // otherwise auto-detect by the global tenant field when the model actually has it.
35
+ if (modelOpts.tenant !== undefined) {
36
+ resource.tenant = modelOpts.tenant;
37
+ }
38
+ else if (options.tenantField &&
39
+ adapter.fields.some((f) => f.name === options.tenantField)) {
40
+ resource.tenant = { field: options.tenantField };
41
+ }
37
42
  if (modelOpts.requiredPermissions)
38
43
  resource.requiredPermissions = modelOpts.requiredPermissions;
39
44
  const defaultLimit = modelOpts.defaultLimit ?? options.defaultLimit;
@@ -48,4 +53,3 @@ export function createPrismaResources(prismaClient, schema, options = {}) {
48
53
  return resource;
49
54
  });
50
55
  }
51
- //# sourceMappingURL=createPrismaResources.js.map
@@ -24,4 +24,3 @@ export declare function toOrderBy(orderBy?: ListOptions['orderBy']): Array<Recor
24
24
  * @returns The route prefix, e.g., 'user-profiles'.
25
25
  */
26
26
  export declare function toRoutePrefix(modelName: string): string;
27
- //# sourceMappingURL=helpers.d.ts.map
@@ -42,4 +42,3 @@ export function toRoutePrefix(modelName) {
42
42
  return kebab + 'es';
43
43
  return kebab + 's';
44
44
  }
45
- //# sourceMappingURL=helpers.js.map
@@ -1,4 +1,3 @@
1
- export type { PrismaDelegate, PrismaNativeClient, PrismaAdapterOptions, CreatePrismaResourcesOptions } from './types.js';
1
+ export type { PrismaDelegate, PrismaAdapterOptions, CreatePrismaResourcesOptions } from './types.js';
2
2
  export { PrismaAdapter } from './PrismaAdapter.js';
3
3
  export { createPrismaResources } from './createPrismaResources.js';
4
- //# sourceMappingURL=index.d.ts.map
@@ -1,3 +1,2 @@
1
1
  export { PrismaAdapter } from './PrismaAdapter.js';
2
2
  export { createPrismaResources } from './createPrismaResources.js';
3
- //# sourceMappingURL=index.js.map
@@ -1,4 +1,4 @@
1
- import type { ModelSchema, ModelResourceOptions, CrudPermissions } from '../../../core/types.js';
1
+ import type { ModelSchema, ModelResourceOptions, CrudPermissions, TenantScope } from '../../../core/types.js';
2
2
  /**
3
3
  * Minimal Prisma delegate interface needed for CRUD operations. This is not a full Prisma client,
4
4
  * but only the methods required by the adapter. The actual Prisma client will have more methods
@@ -23,19 +23,18 @@ export interface PrismaDelegate {
23
23
  count: number;
24
24
  }>;
25
25
  }
26
- /** Minimal Prisma client interface needed for raw SQL execution. */
27
- export interface PrismaNativeClient {
28
- $queryRawUnsafe?<T = unknown>(query: string, ...values: unknown[]): Promise<T>;
29
- $executeRawUnsafe?<T = unknown>(query: string, ...values: unknown[]): Promise<T>;
30
- }
31
26
  /** Construction options for {@link PrismaAdapter}. */
32
27
  export interface PrismaAdapterOptions {
33
28
  delegate: PrismaDelegate;
34
- client?: PrismaNativeClient;
35
29
  idField?: string;
36
- tableName?: string;
37
30
  returnCreated?: boolean;
38
31
  model?: ModelSchema;
32
+ /**
33
+ * Tenant constraint bound to this adapter instance. Set indirectly via
34
+ * {@link PrismaAdapter.withScope}; when present, every operation is confined to
35
+ * `scope.value` on `scope.field`. Leave undefined for unscoped (global) access.
36
+ */
37
+ scope?: TenantScope;
39
38
  }
40
39
  /** Global options for {@link createPrismaResources}. */
41
40
  export interface CreatePrismaResourcesOptions {
@@ -45,5 +44,11 @@ export interface CreatePrismaResourcesOptions {
45
44
  maxLimit?: number;
46
45
  idField?: string;
47
46
  returnCreated?: boolean;
47
+ /**
48
+ * Tenant column to scope on. Every generated resource that has a scalar field with
49
+ * this name is marked tenant-scoped on it (`resource.tenant = { field }`), unless the
50
+ * model overrides or opts out via {@link ModelResourceOptions.tenant}. Pair this with
51
+ * the API-level `tenant` option (the value resolver) to enforce isolation.
52
+ */
53
+ tenantField?: string;
48
54
  }
49
- //# sourceMappingURL=types.d.ts.map
@@ -1,2 +1 @@
1
1
  export {};
2
- //# sourceMappingURL=types.js.map
@@ -187,12 +187,3 @@ export declare class PassportSessionStrategy implements AuthStrategy {
187
187
  */
188
188
  authorize(params: AuthorizeParams): boolean;
189
189
  }
190
- /** @deprecated Use {@link AuthStrategy} instead. */
191
- export type AuthProvider = AuthStrategy;
192
- /** @deprecated Use {@link AllowAllAuthStrategy} instead. */
193
- export declare const AllowAllAuthProvider: typeof AllowAllAuthStrategy;
194
- /** @deprecated Use {@link ApiKeyAuthStrategy} instead. */
195
- export declare const ApiKeyAuthProvider: typeof ApiKeyAuthStrategy;
196
- /** @deprecated Use {@link JwtClaimsAuthStrategy} instead. */
197
- export declare const PermissionAuthProvider: typeof JwtClaimsAuthStrategy;
198
- //# sourceMappingURL=AuthStrategy.d.ts.map
@@ -218,10 +218,3 @@ export class PassportSessionStrategy {
218
218
  return params.requiredPermissions.every((p) => permissions.has(p) || roles.has(p));
219
219
  }
220
220
  }
221
- /** @deprecated Use {@link AllowAllAuthStrategy} instead. */
222
- export const AllowAllAuthProvider = AllowAllAuthStrategy;
223
- /** @deprecated Use {@link ApiKeyAuthStrategy} instead. */
224
- export const ApiKeyAuthProvider = ApiKeyAuthStrategy;
225
- /** @deprecated Use {@link JwtClaimsAuthStrategy} instead. */
226
- export const PermissionAuthProvider = JwtClaimsAuthStrategy;
227
- //# sourceMappingURL=AuthStrategy.js.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Pluggable cache backend. The default is `InMemoryCacheStore`; supply your own
3
+ * (Redis, Memcached, etc.) via the API's cache options to share a cache across processes.
4
+ * All methods may be async so network-backed stores fit the same contract.
5
+ */
6
+ export interface CacheStore {
7
+ /**
8
+ * Read a cached value.
9
+ * @param key - Cache key.
10
+ * @returns The stored value, or `undefined` when absent or expired.
11
+ */
12
+ get(key: string): Promise<unknown> | unknown;
13
+ /**
14
+ * Write a cached value.
15
+ * @param key - Cache key.
16
+ * @param value - Value to store.
17
+ * @param ttlSeconds - Time-to-live in seconds. Omit/`undefined` (or `0`) for no expiry.
18
+ */
19
+ set(key: string, value: unknown, ttlSeconds?: number): Promise<void> | void;
20
+ /**
21
+ * Delete a cached value.
22
+ * @param key - Cache key to remove.
23
+ */
24
+ delete(key: string): Promise<void> | void;
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import type { Repository } from '../../core/types.js';
2
+ import type { CacheStore } from './CacheStore.js';
3
+ /** Options for {@link createCachingRepository}. */
4
+ export interface CachingRepositoryOptions {
5
+ /** Backing cache store. */
6
+ store: CacheStore;
7
+ /** Time-to-live for cached reads, in seconds. `0` means **never expire** (cache forever). */
8
+ ttlSeconds: number;
9
+ /**
10
+ * Key namespace for this repository — must be unique per (resource, tenant) so one
11
+ * tenant can never read another's cached rows. The router builds this from the resource
12
+ * name and the resolved tenant scope value.
13
+ */
14
+ namespace: string;
15
+ /**
16
+ * When `true`, reads bypass the cache lookup, fetch fresh from the underlying repository,
17
+ * and overwrite the cached entry. Set per-request from the cache-bust header so a client
18
+ * can force-refresh on demand. Writes still invalidate as usual.
19
+ */
20
+ bust?: boolean;
21
+ }
22
+ /**
23
+ * Wraps a {@link Repository} with read-through caching and write invalidation.
24
+ *
25
+ * - **Reads** (`getOne`, `getMany`, `executeQuery`) are cached under a versioned key.
26
+ * - **Writes** (`createOne/Many`, `updateOne/Many`, `upsertOne`, `deleteOne/Many`) bump the
27
+ * namespace version, so every previously-cached read for this resource/tenant is instantly
28
+ * stale (old keys simply fall out by TTL — no scan needed, which keeps Redis cheap).
29
+ *
30
+ * Caching sits at the repository layer, *below* the router's auth checks, so every request
31
+ * is still authenticated and authorized — only the database round-trip is skipped. Optional
32
+ * methods are only exposed when the wrapped repository implements them, so the router's
33
+ * capability probing (`if (!repo.executeQuery) …`) keeps working.
34
+ *
35
+ * @param repo - The repository to wrap (already tenant-scoped by the caller, if applicable).
36
+ * @param options - Cache store, TTL, namespace, and optional bust flag.
37
+ * @returns A repository with the same surface as `repo`, plus caching.
38
+ */
39
+ export declare function createCachingRepository<TRecord, TCreate, TUpdate>(repo: Repository<TRecord, TCreate, TUpdate>, options: CachingRepositoryOptions): Repository<TRecord, TCreate, TUpdate>;
@@ -0,0 +1,116 @@
1
+ /** Produces a deterministic string for a value so equal queries hash to the same key. */
2
+ function stableStringify(value) {
3
+ if (value === null || typeof value !== 'object')
4
+ return JSON.stringify(value) ?? 'null';
5
+ if (Array.isArray(value))
6
+ return `[${value.map(stableStringify).join(',')}]`;
7
+ const keys = Object.keys(value).sort();
8
+ return `{${keys
9
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
10
+ .join(',')}}`;
11
+ }
12
+ /**
13
+ * Wraps a {@link Repository} with read-through caching and write invalidation.
14
+ *
15
+ * - **Reads** (`getOne`, `getMany`, `executeQuery`) are cached under a versioned key.
16
+ * - **Writes** (`createOne/Many`, `updateOne/Many`, `upsertOne`, `deleteOne/Many`) bump the
17
+ * namespace version, so every previously-cached read for this resource/tenant is instantly
18
+ * stale (old keys simply fall out by TTL — no scan needed, which keeps Redis cheap).
19
+ *
20
+ * Caching sits at the repository layer, *below* the router's auth checks, so every request
21
+ * is still authenticated and authorized — only the database round-trip is skipped. Optional
22
+ * methods are only exposed when the wrapped repository implements them, so the router's
23
+ * capability probing (`if (!repo.executeQuery) …`) keeps working.
24
+ *
25
+ * @param repo - The repository to wrap (already tenant-scoped by the caller, if applicable).
26
+ * @param options - Cache store, TTL, namespace, and optional bust flag.
27
+ * @returns A repository with the same surface as `repo`, plus caching.
28
+ */
29
+ export function createCachingRepository(repo, options) {
30
+ const { store, ttlSeconds, namespace, bust = false } = options;
31
+ const versionKey = `${namespace}:__ver`;
32
+ const readVersion = async () => {
33
+ const v = await store.get(versionKey);
34
+ return typeof v === 'number' ? v : 0;
35
+ };
36
+ const bumpVersion = async () => {
37
+ await store.set(versionKey, (await readVersion()) + 1);
38
+ };
39
+ const keyFor = async (op, payload) => `${namespace}:v${await readVersion()}:${op}:${stableStringify(payload)}`;
40
+ const cachedRead = async (op, payload, run) => {
41
+ const key = await keyFor(op, payload);
42
+ // A cache-bust request skips the lookup and force-refreshes the entry.
43
+ if (!bust) {
44
+ const hit = await store.get(key);
45
+ if (hit !== undefined)
46
+ return hit;
47
+ }
48
+ const value = await run();
49
+ await store.set(key, value, ttlSeconds);
50
+ return value;
51
+ };
52
+ const wrapped = {
53
+ ...(repo.capabilities ? { capabilities: repo.capabilities } : {}),
54
+ getOne(id, getOptions) {
55
+ return cachedRead('getOne', { id, getOptions }, () => repo.getOne(id, getOptions));
56
+ },
57
+ getMany(listOptions) {
58
+ return cachedRead('getMany', listOptions ?? {}, () => repo.getMany(listOptions));
59
+ },
60
+ async createOne(data, createOptions) {
61
+ const result = await repo.createOne(data, createOptions);
62
+ await bumpVersion();
63
+ return result;
64
+ },
65
+ async createMany(data, createOptions) {
66
+ const result = await repo.createMany(data, createOptions);
67
+ await bumpVersion();
68
+ return result;
69
+ },
70
+ async updateOne(id, data) {
71
+ const result = await repo.updateOne(id, data);
72
+ await bumpVersion();
73
+ return result;
74
+ },
75
+ async deleteOne(id) {
76
+ const result = await repo.deleteOne(id);
77
+ await bumpVersion();
78
+ return result;
79
+ }
80
+ };
81
+ // Optional methods: only expose (and invalidate) when the underlying repository has them,
82
+ // so the router's `if (!repo.x)` capability checks behave identically to an uncached repo.
83
+ if (repo.executeQuery) {
84
+ wrapped.executeQuery = (query) => cachedRead('query', query, () => repo.executeQuery(query));
85
+ }
86
+ if (repo.updateMany) {
87
+ wrapped.updateMany = async (query, data) => {
88
+ const result = await repo.updateMany(query, data);
89
+ await bumpVersion();
90
+ return result;
91
+ };
92
+ }
93
+ if (repo.upsertOne) {
94
+ wrapped.upsertOne = async (id, data) => {
95
+ const result = await repo.upsertOne(id, data);
96
+ await bumpVersion();
97
+ return result;
98
+ };
99
+ }
100
+ if (repo.deleteMany) {
101
+ wrapped.deleteMany = async (query) => {
102
+ const result = await repo.deleteMany(query);
103
+ await bumpVersion();
104
+ return result;
105
+ };
106
+ }
107
+ if (repo.withScope) {
108
+ wrapped.withScope = (scope) => createCachingRepository(repo.withScope(scope), {
109
+ store,
110
+ ttlSeconds,
111
+ bust,
112
+ namespace: `${namespace}:${String(scope.value)}`
113
+ });
114
+ }
115
+ return wrapped;
116
+ }
@@ -0,0 +1,19 @@
1
+ import type { CacheStore } from '../CacheStore.js';
2
+ /**
3
+ * Default in-process {@link CacheStore} backed by a `Map`, with per-entry TTL.
4
+ *
5
+ * Suitable for single-process deployments and tests. For multi-instance deployments,
6
+ * inject a shared store (e.g. `RedisCacheStore`) instead so cache and invalidation are
7
+ * consistent across processes.
8
+ */
9
+ export declare class InMemoryCacheStore implements CacheStore {
10
+ private readonly now;
11
+ private readonly map;
12
+ /**
13
+ * @param now - Clock function (ms epoch). Injectable for tests; defaults to `Date.now`.
14
+ */
15
+ constructor(now?: () => number);
16
+ get(key: string): unknown;
17
+ set(key: string, value: unknown, ttlSeconds?: number): void;
18
+ delete(key: string): void;
19
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Default in-process {@link CacheStore} backed by a `Map`, with per-entry TTL.
3
+ *
4
+ * Suitable for single-process deployments and tests. For multi-instance deployments,
5
+ * inject a shared store (e.g. `RedisCacheStore`) instead so cache and invalidation are
6
+ * consistent across processes.
7
+ */
8
+ export class InMemoryCacheStore {
9
+ now;
10
+ map = new Map();
11
+ /**
12
+ * @param now - Clock function (ms epoch). Injectable for tests; defaults to `Date.now`.
13
+ */
14
+ constructor(now = () => Date.now()) {
15
+ this.now = now;
16
+ }
17
+ get(key) {
18
+ const entry = this.map.get(key);
19
+ if (!entry)
20
+ return undefined;
21
+ if (entry.expiresAt !== null && entry.expiresAt <= this.now()) {
22
+ this.map.delete(key);
23
+ return undefined;
24
+ }
25
+ return entry.value;
26
+ }
27
+ set(key, value, ttlSeconds) {
28
+ const expiresAt = ttlSeconds && ttlSeconds > 0 ? this.now() + ttlSeconds * 1000 : null;
29
+ this.map.set(key, { value, expiresAt });
30
+ }
31
+ delete(key) {
32
+ this.map.delete(key);
33
+ }
34
+ }
@@ -0,0 +1,5 @@
1
+ export * from './CacheStore.js';
2
+ export * from './createCachingRepository.js';
3
+ export * from './in-memory/InMemoryCacheStore.js';
4
+ export * from './redis/RedisLikeClient.js';
5
+ export * from './redis/RedisCacheStore.js';
@@ -0,0 +1,5 @@
1
+ export * from './CacheStore.js';
2
+ export * from './createCachingRepository.js';
3
+ export * from './in-memory/InMemoryCacheStore.js';
4
+ export * from './redis/RedisLikeClient.js';
5
+ export * from './redis/RedisCacheStore.js';
@@ -0,0 +1,28 @@
1
+ import type { CacheStore } from '../CacheStore.js';
2
+ import type { RedisLikeClient } from './RedisLikeClient.js';
3
+ /**
4
+ * A {@link CacheStore} backed by Redis, for sharing cache and invalidation across processes
5
+ * and instances. Values are JSON-serialised; TTL maps to Redis `EX` (a TTL of `0`/omitted
6
+ * stores the key with no expiry).
7
+ *
8
+ * ```ts
9
+ * import { createClient } from 'redis'
10
+ * const client = createClient({ url: process.env.REDIS_URL })
11
+ * await client.connect()
12
+ * const store = new RedisCacheStore(client, { keyPrefix: 'halifax:' })
13
+ * ```
14
+ */
15
+ export declare class RedisCacheStore implements CacheStore {
16
+ private readonly client;
17
+ private readonly prefix;
18
+ /**
19
+ * @param client - A connected Redis client matching {@link RedisLikeClient}.
20
+ * @param options - Optional `keyPrefix` prepended to every key (namespacing a shared Redis).
21
+ */
22
+ constructor(client: RedisLikeClient, options?: {
23
+ keyPrefix?: string;
24
+ });
25
+ get(key: string): Promise<unknown>;
26
+ set(key: string, value: unknown, ttlSeconds?: number): Promise<void>;
27
+ delete(key: string): Promise<void>;
28
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * A {@link CacheStore} backed by Redis, for sharing cache and invalidation across processes
3
+ * and instances. Values are JSON-serialised; TTL maps to Redis `EX` (a TTL of `0`/omitted
4
+ * stores the key with no expiry).
5
+ *
6
+ * ```ts
7
+ * import { createClient } from 'redis'
8
+ * const client = createClient({ url: process.env.REDIS_URL })
9
+ * await client.connect()
10
+ * const store = new RedisCacheStore(client, { keyPrefix: 'halifax:' })
11
+ * ```
12
+ */
13
+ export class RedisCacheStore {
14
+ client;
15
+ prefix;
16
+ /**
17
+ * @param client - A connected Redis client matching {@link RedisLikeClient}.
18
+ * @param options - Optional `keyPrefix` prepended to every key (namespacing a shared Redis).
19
+ */
20
+ constructor(client, options = {}) {
21
+ this.client = client;
22
+ this.prefix = options.keyPrefix ?? '';
23
+ }
24
+ async get(key) {
25
+ const raw = await this.client.get(this.prefix + key);
26
+ if (raw === null || raw === undefined)
27
+ return undefined;
28
+ return JSON.parse(raw);
29
+ }
30
+ async set(key, value, ttlSeconds) {
31
+ const payload = JSON.stringify(value);
32
+ if (ttlSeconds && ttlSeconds > 0) {
33
+ await this.client.set(this.prefix + key, payload, { EX: ttlSeconds });
34
+ }
35
+ else {
36
+ await this.client.set(this.prefix + key, payload);
37
+ }
38
+ }
39
+ async delete(key) {
40
+ await this.client.del(this.prefix + key);
41
+ }
42
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The slice of a Redis client {@link RedisCacheStore} uses. It is intentionally tiny and
3
+ * duck-typed so any client matching it works (`redis` v4, etc.) without Halifax depending on
4
+ * a specific Redis package. (`redis` v4: `get` / `set(key, val, { EX })` / `del` all match.)
5
+ */
6
+ export interface RedisLikeClient {
7
+ get(key: string): Promise<string | null>;
8
+ set(key: string, value: string, options?: {
9
+ EX?: number;
10
+ }): Promise<unknown>;
11
+ del(key: string): Promise<unknown>;
12
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,14 +1,80 @@
1
- import { type AuthStrategy } from '../auth/AuthStrategy.js';
1
+ import { type AuthContext, type AuthStrategy } from '../auth/AuthStrategy.js';
2
+ import { type CacheStore } from '../core/cache/index.js';
2
3
  import { type ResourceDefinition } from '../core/types.js';
3
- import type { HttpServer } from '../core/types.js';
4
+ import type { HttpRequest, HttpServer } from '../core/types.js';
5
+ /** Context handed to {@link TenantOptions.resolveId} for the current request. */
6
+ export interface TenantResolveContext {
7
+ /** The resolved authentication context for the request. */
8
+ auth: AuthContext;
9
+ /** The incoming HTTP request. */
10
+ req: HttpRequest;
11
+ /** The resource being accessed. */
12
+ resource: ResourceDefinition;
13
+ }
14
+ /**
15
+ * Configures multi-tenant isolation for the whole API. When set, every resource that
16
+ * is tenant-scoped (see {@link ResourceDefinition.tenant}) has all of its reads, writes,
17
+ * and bulk operations confined to the tenant value returned by {@link TenantOptions.resolveId}.
18
+ */
19
+ export interface TenantOptions {
20
+ /**
21
+ * Resolve the tenant key the current caller is bound to (e.g. their company id),
22
+ * derived from the authenticated session/token — never from client-supplied input.
23
+ * Return `null`/`undefined` to signal "no tenant"; combined with {@link TenantOptions.strict}
24
+ * this either denies the request (default) or serves it unscoped.
25
+ * @param ctx - The auth context, request, and resource being accessed.
26
+ * @returns The tenant key, or null/undefined when none applies.
27
+ */
28
+ resolveId: (ctx: TenantResolveContext) => unknown | Promise<unknown>;
29
+ /**
30
+ * Default tenant column name used to auto-detect scoping: any resource that has a
31
+ * field with this name (and no explicit {@link ResourceDefinition.tenant}) is scoped
32
+ * on it. Defaults to `'tenantId'`.
33
+ */
34
+ field?: string;
35
+ /**
36
+ * Fail-closed switch. When `true` (the default), a tenant-scoped resource whose
37
+ * {@link TenantOptions.resolveId} returns no value rejects the request with 403 rather
38
+ * than serving unscoped data. Only set to `false` if you deliberately allow
39
+ * cross-tenant ("god mode") access for callers with no tenant.
40
+ */
41
+ strict?: boolean;
42
+ }
4
43
  /** Options for {@link registerCrudApi} / {@link createExpressCrudRouter}. */
5
44
  export interface CrudApiOptions {
6
45
  /** Auth strategy used for all routes. Defaults to {@link AllowAllAuthStrategy}. */
7
46
  authStrategy?: AuthStrategy;
8
- /** Path segment for the query-builder POST route (default: `'query-builder'`). */
47
+ /** Multi-tenant isolation config. When omitted, no tenant scoping is applied. */
48
+ tenant?: TenantOptions;
49
+ /** Path segment for the query-builder POST route (default: `'query'`). */
9
50
  queryBuilderPath?: string;
10
- /** Path for the query-builder preview route (default: `'/query-builder/preview'`). */
11
- previewQueryBuilderPath?: string;
51
+ /**
52
+ * Wrap every success response body under a single key (e.g. `'data'` → `{ "data": <body> }`)
53
+ * for all resources. Per-resource {@link ResourceDefinition.envelope} takes precedence.
54
+ * Error responses are never enveloped. Omit (or set `null`/`''`) for bare bodies — the
55
+ * default, and backward compatible.
56
+ */
57
+ envelope?: string | null;
58
+ /**
59
+ * API-wide read-through caching. Provide a `store` (defaults to an in-process
60
+ * {@link InMemoryCacheStore}) and/or a default `ttlSeconds` applied to every resource that
61
+ * doesn't set its own {@link ResourceDefinition.cache}. Per-resource config takes precedence.
62
+ */
63
+ cache?: {
64
+ /** Backing cache store shared by all resources. Defaults to an in-process store. */
65
+ store?: CacheStore;
66
+ /**
67
+ * Default TTL (seconds) applied to all resources lacking their own cache config.
68
+ * `0` means never expire; omit to leave caching off by default.
69
+ */
70
+ ttlSeconds?: number;
71
+ /**
72
+ * Request header that force-refreshes the cache for that request. Defaults to
73
+ * `'Cache-Control'` (busts when the value contains `no-cache`/`no-store`). Set a custom
74
+ * header name to bust whenever that header is present with any value.
75
+ */
76
+ bustHeader?: string;
77
+ };
12
78
  }
13
79
  /**
14
80
  * Converts any thrown value to a structured `{ status, code, message, details }` object.
@@ -26,11 +92,9 @@ export declare function normalizeError(error: unknown): {
26
92
  * Registers all CRUD routes for every resource on the given HTTP server.
27
93
  *
28
94
  * Routes are controlled by `resource.permissions` merged with {@link defaultCrudPermissions}.
29
- * A global query-builder preview endpoint is also registered at `previewQueryBuilderPath`.
30
95
  *
31
96
  * @param server - The HTTP server adapter to register routes on (e.g. {@link ExpressHttpServer}).
32
97
  * @param resources - Resource definitions to wire up as CRUD endpoints.
33
- * @param options - Auth strategy, query-builder path overrides, and preview path overrides.
98
+ * @param options - Auth strategy and query-builder path overrides.
34
99
  */
35
100
  export declare function registerCrudApi(server: HttpServer, resources: ResourceDefinition[], options?: CrudApiOptions): void;
36
- //# sourceMappingURL=crudRouter.d.ts.map