@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.
- package/CHANGELOG.md +97 -0
- package/README.md +72 -50
- package/README_AUTOCRUD.md +94 -19
- package/README_QUERYBUILDER.md +1 -1
- package/README_REPO_ADAPTERS.md +80 -11
- package/dist/adapters/http/ExpressAdapter.d.ts +34 -5
- package/dist/adapters/http/ExpressAdapter.js +20 -12
- package/dist/adapters/http/FastifyAdapter.d.ts +93 -0
- package/dist/adapters/http/FastifyAdapter.js +125 -0
- package/dist/adapters/http/HyperExpressAdapter.d.ts +82 -0
- package/dist/adapters/http/HyperExpressAdapter.js +128 -0
- package/dist/adapters/http/UltimateExpressAdapter.d.ts +84 -0
- package/dist/adapters/http/UltimateExpressAdapter.js +108 -0
- package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +89 -40
- package/dist/adapters/orm/prisma/PrismaAdapter.js +233 -71
- package/dist/adapters/orm/prisma/astToPrisma.d.ts +26 -0
- package/dist/adapters/orm/prisma/astToPrisma.js +140 -0
- package/dist/adapters/orm/prisma/createPrismaResources.d.ts +1 -2
- package/dist/adapters/orm/prisma/createPrismaResources.js +10 -6
- package/dist/adapters/orm/prisma/helpers.d.ts +0 -1
- package/dist/adapters/orm/prisma/helpers.js +0 -1
- package/dist/adapters/orm/prisma/index.d.ts +1 -2
- package/dist/adapters/orm/prisma/index.js +0 -1
- package/dist/adapters/orm/prisma/types.d.ts +14 -9
- package/dist/adapters/orm/prisma/types.js +0 -1
- package/dist/auth/AuthStrategy.d.ts +0 -9
- package/dist/auth/AuthStrategy.js +0 -7
- package/dist/core/cache/CacheStore.d.ts +25 -0
- package/dist/core/cache/CacheStore.js +1 -0
- package/dist/core/cache/createCachingRepository.d.ts +39 -0
- package/dist/core/cache/createCachingRepository.js +116 -0
- package/dist/core/cache/in-memory/InMemoryCacheStore.d.ts +19 -0
- package/dist/core/cache/in-memory/InMemoryCacheStore.js +34 -0
- package/dist/core/cache/index.d.ts +5 -0
- package/dist/core/cache/index.js +5 -0
- package/dist/core/cache/redis/RedisCacheStore.d.ts +28 -0
- package/dist/core/cache/redis/RedisCacheStore.js +42 -0
- package/dist/core/cache/redis/RedisLikeClient.d.ts +12 -0
- package/dist/core/cache/redis/RedisLikeClient.js +1 -0
- package/dist/core/crudRouter.d.ts +72 -8
- package/dist/core/crudRouter.js +266 -105
- package/dist/core/queryString.d.ts +3 -3
- package/dist/core/queryString.js +16 -7
- package/dist/core/types.d.ts +151 -31
- package/dist/core/types.js +13 -1
- package/dist/core/validation.d.ts +12 -4
- package/dist/core/validation.js +33 -13
- package/dist/enums/SqlComparison.d.ts +13 -3
- package/dist/enums/SqlComparison.js +12 -2
- package/dist/enums/SqlOperator.d.ts +0 -1
- package/dist/enums/SqlOperator.js +0 -1
- package/dist/enums/SqlOrder.d.ts +0 -1
- package/dist/enums/SqlOrder.js +0 -1
- package/dist/errors/AuthenticationError.d.ts +0 -1
- package/dist/errors/AuthenticationError.js +0 -1
- package/dist/errors/AuthorizationError.d.ts +0 -1
- package/dist/errors/AuthorizationError.js +0 -1
- package/dist/errors/BadRequestError.d.ts +0 -1
- package/dist/errors/BadRequestError.js +0 -1
- package/dist/errors/HttpError.d.ts +0 -1
- package/dist/errors/HttpError.js +0 -1
- package/dist/errors/MethodNotAllowedError.d.ts +0 -1
- package/dist/errors/MethodNotAllowedError.js +0 -1
- package/dist/errors/NotAcceptableError.d.ts +0 -1
- package/dist/errors/NotAcceptableError.js +0 -1
- package/dist/errors/NotFoundError.d.ts +0 -1
- package/dist/errors/NotFoundError.js +0 -1
- package/dist/errors/NotImplementedError.d.ts +0 -1
- package/dist/errors/NotImplementedError.js +0 -1
- package/dist/errors/ServerError.d.ts +0 -1
- package/dist/errors/ServerError.js +0 -1
- package/dist/errors/UnprocessableEntityError.d.ts +0 -1
- package/dist/errors/UnprocessableEntityError.js +0 -1
- package/dist/errors/UnsupportedMediaTypeError.d.ts +0 -1
- package/dist/errors/UnsupportedMediaTypeError.js +0 -1
- package/dist/index.d.ts +1 -3
- package/dist/index.js +1 -3
- package/dist/interfaces/IQueryFilter.d.ts +1 -2
- package/dist/interfaces/IQueryFilter.js +0 -1
- package/dist/interfaces/IQueryOptions.d.ts +9 -9
- package/dist/interfaces/IQueryOptions.js +0 -1
- package/dist/interfaces/ISort.d.ts +0 -1
- package/dist/interfaces/ISort.js +0 -1
- package/package.json +10 -8
- package/dist/adapters/http/ExpressAdapter.d.ts.map +0 -1
- package/dist/adapters/http/ExpressAdapter.js.map +0 -1
- package/dist/adapters/orm/prisma/PrismaAdapter.d.ts.map +0 -1
- package/dist/adapters/orm/prisma/PrismaAdapter.js.map +0 -1
- package/dist/adapters/orm/prisma/createPrismaResources.d.ts.map +0 -1
- package/dist/adapters/orm/prisma/createPrismaResources.js.map +0 -1
- package/dist/adapters/orm/prisma/helpers.d.ts.map +0 -1
- package/dist/adapters/orm/prisma/helpers.js.map +0 -1
- package/dist/adapters/orm/prisma/index.d.ts.map +0 -1
- package/dist/adapters/orm/prisma/index.js.map +0 -1
- package/dist/adapters/orm/prisma/types.d.ts.map +0 -1
- package/dist/adapters/orm/prisma/types.js.map +0 -1
- package/dist/auth/AuthStrategy.d.ts.map +0 -1
- package/dist/auth/AuthStrategy.js.map +0 -1
- package/dist/classes/QueryBuilder.d.ts +0 -33
- package/dist/classes/QueryBuilder.d.ts.map +0 -1
- package/dist/classes/QueryBuilder.js +0 -262
- package/dist/classes/QueryBuilder.js.map +0 -1
- package/dist/core/crudRouter.d.ts.map +0 -1
- package/dist/core/crudRouter.js.map +0 -1
- package/dist/core/queryString.d.ts.map +0 -1
- package/dist/core/queryString.js.map +0 -1
- package/dist/core/types.d.ts.map +0 -1
- package/dist/core/types.js.map +0 -1
- package/dist/core/validation.d.ts.map +0 -1
- package/dist/core/validation.js.map +0 -1
- package/dist/enums/SqlComparison.d.ts.map +0 -1
- package/dist/enums/SqlComparison.js.map +0 -1
- package/dist/enums/SqlOperator.d.ts.map +0 -1
- package/dist/enums/SqlOperator.js.map +0 -1
- package/dist/enums/SqlOrder.d.ts.map +0 -1
- package/dist/enums/SqlOrder.js.map +0 -1
- package/dist/errors/AuthenticationError.d.ts.map +0 -1
- package/dist/errors/AuthenticationError.js.map +0 -1
- package/dist/errors/AuthorizationError.d.ts.map +0 -1
- package/dist/errors/AuthorizationError.js.map +0 -1
- package/dist/errors/BadRequestError.d.ts.map +0 -1
- package/dist/errors/BadRequestError.js.map +0 -1
- package/dist/errors/HttpError.d.ts.map +0 -1
- package/dist/errors/HttpError.js.map +0 -1
- package/dist/errors/MethodNotAllowedError.d.ts.map +0 -1
- package/dist/errors/MethodNotAllowedError.js.map +0 -1
- package/dist/errors/NotAcceptableError.d.ts.map +0 -1
- package/dist/errors/NotAcceptableError.js.map +0 -1
- package/dist/errors/NotFoundError.d.ts.map +0 -1
- package/dist/errors/NotFoundError.js.map +0 -1
- package/dist/errors/NotImplementedError.d.ts.map +0 -1
- package/dist/errors/NotImplementedError.js.map +0 -1
- package/dist/errors/ServerError.d.ts.map +0 -1
- package/dist/errors/ServerError.js.map +0 -1
- package/dist/errors/UnprocessableEntityError.d.ts.map +0 -1
- package/dist/errors/UnprocessableEntityError.js.map +0 -1
- package/dist/errors/UnsupportedMediaTypeError.d.ts.map +0 -1
- package/dist/errors/UnsupportedMediaTypeError.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/interfaces/IParamQuery.d.ts +0 -8
- package/dist/interfaces/IParamQuery.d.ts.map +0 -1
- package/dist/interfaces/IParamQuery.js +0 -2
- package/dist/interfaces/IParamQuery.js.map +0 -1
- package/dist/interfaces/IQueryFilter.d.ts.map +0 -1
- package/dist/interfaces/IQueryFilter.js.map +0 -1
- package/dist/interfaces/IQueryOptions.d.ts.map +0 -1
- package/dist/interfaces/IQueryOptions.js.map +0 -1
- package/dist/interfaces/ISort.d.ts.map +0 -1
- 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
|
|
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
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
export type { PrismaDelegate,
|
|
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,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
|
|
@@ -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,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
|
-
/**
|
|
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
|
-
/**
|
|
11
|
-
|
|
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
|
|
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
|