@edium/halifax 2.2.3 → 2.4.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 +202 -0
- package/README.md +6 -3
- package/README_AUTH.md +2 -2
- package/README_AUTOCRUD.md +5 -4
- package/README_CACHE.md +6 -0
- package/README_CLASSES.md +13 -6
- package/README_GRAPHQL.md +352 -0
- package/README_INTERFACES.md +19 -14
- package/README_MULTITENANCY.md +87 -0
- package/README_OPENAPI.md +9 -9
- package/README_REPO_ADAPTERS.md +10 -0
- package/dist/adapters/orm/drizzle/DrizzleAdapter.d.ts +5 -0
- package/dist/adapters/orm/drizzle/DrizzleAdapter.js +9 -1
- package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +1 -2
- package/dist/adapters/orm/prisma/PrismaAdapter.js +106 -34
- package/dist/adapters/orm/prisma/astToPrisma.js +6 -0
- package/dist/auth/strategies/JwtClaimsAuthStrategy.js +2 -8
- package/dist/auth/strategies/PassportStrategies.js +3 -9
- package/dist/auth/strategies/types.d.ts +7 -0
- package/dist/auth/strategies/types.js +13 -1
- package/dist/core/cache/CacheStore.d.ts +12 -0
- package/dist/core/cache/createCachingRepository.js +10 -1
- package/dist/core/cache/in-memory/InMemoryCacheStore.d.ts +14 -1
- package/dist/core/cache/in-memory/InMemoryCacheStore.js +29 -1
- package/dist/core/cache/redis/RedisCacheStore.d.ts +6 -0
- package/dist/core/cache/redis/RedisCacheStore.js +14 -0
- package/dist/core/cache/redis/RedisLikeClient.d.ts +2 -0
- package/dist/core/crudRouter.d.ts +38 -0
- package/dist/core/crudRouter.js +55 -21
- package/dist/core/fields.d.ts +11 -1
- package/dist/core/fields.js +19 -0
- package/dist/core/handlerUtils.d.ts +7 -1
- package/dist/core/handlerUtils.js +15 -11
- package/dist/core/handlers/create.js +4 -3
- package/dist/core/handlers/deleteMany.js +1 -1
- package/dist/core/handlers/deleteOne.js +1 -1
- package/dist/core/handlers/query.js +4 -6
- package/dist/core/handlers/readMany.js +4 -6
- package/dist/core/handlers/readOne.js +4 -7
- package/dist/core/handlers/updateMany.js +4 -5
- package/dist/core/handlers/updateOne.js +1 -1
- package/dist/core/handlers/upsertOne.js +1 -1
- package/dist/core/queryString.d.ts +10 -0
- package/dist/core/queryString.js +23 -0
- package/dist/core/types.d.ts +22 -0
- package/dist/core/validation.js +5 -11
- package/dist/graphql/graphiql.d.ts +5 -0
- package/dist/graphql/graphiql.js +29 -0
- package/dist/graphql/index.d.ts +4 -0
- package/dist/graphql/index.js +3 -0
- package/dist/graphql/registerGraphqlRoute.d.ts +10 -0
- package/dist/graphql/registerGraphqlRoute.js +79 -0
- package/dist/graphql/scalars.d.ts +6 -0
- package/dist/graphql/scalars.js +32 -0
- package/dist/graphql/schema.d.ts +3 -0
- package/dist/graphql/schema.js +635 -0
- package/dist/graphql/types.d.ts +48 -0
- package/dist/graphql/types.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/openapi/specGenerator.js +19 -19
- package/package.json +9 -3
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# Halifax GraphQL
|
|
2
|
+
|
|
3
|
+
Halifax can automatically expose a GraphQL API alongside your REST endpoints. The schema is generated at startup from the same resource definitions that drive REST — no extra annotation or separate schema file required.
|
|
4
|
+
|
|
5
|
+
GraphQL is **opt-in**: nothing happens unless you explicitly set `enabled: true`.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- [Installation](#installation)
|
|
10
|
+
- [Enabling GraphQL](#enabling-graphql)
|
|
11
|
+
- [What gets generated](#what-gets-generated)
|
|
12
|
+
- [Per-resource opt-out](#per-resource-opt-out)
|
|
13
|
+
- [GraphiQL IDE](#graphiql-ide)
|
|
14
|
+
- [Authentication](#authentication)
|
|
15
|
+
- [Tenant scoping and admin bypass](#tenant-scoping-and-admin-bypass)
|
|
16
|
+
- [Query examples](#query-examples)
|
|
17
|
+
- [Mutation examples](#mutation-examples)
|
|
18
|
+
- [Types reference](#types-reference)
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
The `graphql` package is a peer dependency — install it alongside Halifax:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install graphql
|
|
28
|
+
# or
|
|
29
|
+
pnpm add graphql
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Halifax is tested against `graphql` v16.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Enabling GraphQL
|
|
37
|
+
|
|
38
|
+
Pass `graphql: { enabled: true }` to `registerCrudApi` (or `createExpressCrudRouter`):
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { createExpressCrudRouter } from '@edium/halifax'
|
|
42
|
+
|
|
43
|
+
const app = createExpressCrudRouter(resources, {
|
|
44
|
+
graphql: {
|
|
45
|
+
enabled: true, // required — GraphQL is off by default
|
|
46
|
+
path: '/graphql', // default: '/graphql'
|
|
47
|
+
graphiql: true, // default: true — serve the GraphiQL IDE at GET /graphql
|
|
48
|
+
requireAuth: false, // default: false — set true to require auth before any operation
|
|
49
|
+
title: 'My API' // browser tab title in GraphiQL
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Two routes are registered:
|
|
55
|
+
|
|
56
|
+
| Route | Purpose |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `POST /graphql` | Execute GraphQL queries and mutations |
|
|
59
|
+
| `GET /graphql` | GraphiQL browser IDE (disable with `graphiql: false`) |
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## What gets generated
|
|
64
|
+
|
|
65
|
+
For every resource Halifax generates:
|
|
66
|
+
|
|
67
|
+
**Query fields**
|
|
68
|
+
|
|
69
|
+
| Field | REST equivalent | Description |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `get<Resource>(id: ID!)` | `GET /<resource>/:id` | Fetch one record by ID |
|
|
72
|
+
| `list<Resource>(filter, limit, offset, orderBy, include)` | `GET /<resource>` | Paginated list with equality filters |
|
|
73
|
+
| `query<Resource>(where, fields, distinct, limit, offset, orderBy, include)` | `POST /<resource>/query` | Advanced query with full filter expressions |
|
|
74
|
+
|
|
75
|
+
**Mutation fields**
|
|
76
|
+
|
|
77
|
+
| Field | REST equivalent | Description |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `create<Resource>(input)` | `POST /<resource>` | Create one record |
|
|
80
|
+
| `createMany<Resource>(input)` | `POST /<resource>` (array body) | Create multiple records |
|
|
81
|
+
| `update<Resource>(id, input)` | `PATCH /<resource>/:id` | Partial update |
|
|
82
|
+
| `updateMany<Resource>(where, update)` | `PATCH /<resource>` | Bulk update |
|
|
83
|
+
| `upsert<Resource>(id, input)` | `PUT /<resource>/:id` | Create or replace |
|
|
84
|
+
| `delete<Resource>(id)` | `DELETE /<resource>/:id` | Delete one record |
|
|
85
|
+
| `deleteMany<Resource>(where)` | `DELETE /<resource>` | Bulk delete |
|
|
86
|
+
|
|
87
|
+
Only operations allowed by `resource.permissions` are included. If `allowDeleteMany: false`, no `deleteMany<Resource>` mutation is generated.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Per-resource opt-out
|
|
92
|
+
|
|
93
|
+
Set `graphql: false` on any resource to exclude it from the GraphQL schema entirely:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
const resources = [
|
|
97
|
+
{
|
|
98
|
+
routePrefix: 'users',
|
|
99
|
+
repository: userRepo,
|
|
100
|
+
// visible in REST and GraphQL (default)
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
routePrefix: 'audit-logs',
|
|
104
|
+
repository: auditRepo,
|
|
105
|
+
graphql: false, // REST only — excluded from GraphQL
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## GraphiQL IDE
|
|
113
|
+
|
|
114
|
+
When `graphiql: true` (the default), visiting `GET /graphql` in a browser opens the GraphiQL IDE. You can explore the auto-generated schema using introspection, run queries interactively, and inspect the full type system.
|
|
115
|
+
|
|
116
|
+
To disable the IDE (e.g. in production):
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
graphql: { enabled: true, graphiql: false }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Authentication
|
|
125
|
+
|
|
126
|
+
GraphQL uses the same `authStrategy` as REST. Every resolver calls `authenticate` before executing, so your JWT/API-key validation fires automatically.
|
|
127
|
+
|
|
128
|
+
To require authentication before even responding to introspection queries, set `requireAuth: true`:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
graphql: { enabled: true, requireAuth: true }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Fine-grained permissions (`resource.requiredPermissions`) apply per resolver, identical to REST. Per-field `readRoles`/`writeRoles` restrictions are also enforced — restricted fields are stripped from resolver results.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Tenant scoping and admin bypass
|
|
139
|
+
|
|
140
|
+
The GraphQL endpoint respects the same tenant isolation that REST does. Each resolver resolves the tenant from the caller's auth context (JWT claims, session, etc.) and scopes the repository automatically — regular users can only see their own tenant's data. The tenant value is **always derived from auth**, never from client-supplied input.
|
|
141
|
+
|
|
142
|
+
### Admin bypass
|
|
143
|
+
|
|
144
|
+
Admins who need to query across all tenants can be granted bypass access by listing their roles or permission slugs in `TenantOptions.bypassRoles` (API-wide default) or `ResourceDefinition.bypassTenantRoles` (per-resource override).
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
createExpressCrudRouter(resources, {
|
|
148
|
+
tenant: {
|
|
149
|
+
resolveId: (ctx) => ctx.auth.claims?.companyId,
|
|
150
|
+
bypassRoles: ['super_admin', 'support:read-all'], // role OR permission slug
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**How it works:**
|
|
156
|
+
|
|
157
|
+
- **Bypass role → all records.** An admin whose `auth.roles` or `auth.permissions` matches any entry in `bypassRoles` gets an unscoped read — all rows across all tenants.
|
|
158
|
+
- **No bypass role → normal scoping.** Regular users are always scoped to their tenant. The bypass path is unreachable for them.
|
|
159
|
+
- **Writes are never bypassed.** Write operations always resolve the tenant through `resolveId`. An admin whose token carries a `companyId` writes to that company; one without a tenant receives 403 (unless `strict: false`). The tenant field on writes always comes from auth — never from the client.
|
|
160
|
+
|
|
161
|
+
**Narrowing to a single tenant (admin reads):**
|
|
162
|
+
|
|
163
|
+
When an admin wants to see just one tenant's data, they use the same filter mechanism as any other caller — the tenant field is just another filterable column:
|
|
164
|
+
|
|
165
|
+
```graphql
|
|
166
|
+
# All tenants (bypass active, no filter)
|
|
167
|
+
{ listOrders { count results { id companyId total } } }
|
|
168
|
+
|
|
169
|
+
# One specific tenant — admin narrows with a filter
|
|
170
|
+
{ listOrders(filter: { companyId: 42 }) { count results { id companyId total } } }
|
|
171
|
+
|
|
172
|
+
# Or with the full query builder
|
|
173
|
+
{
|
|
174
|
+
queryOrders(where: [
|
|
175
|
+
{ field: "companyId", comparison: "=", value1: 42 }
|
|
176
|
+
]) { count results { id companyId total } }
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
On REST it works the same way:
|
|
181
|
+
```
|
|
182
|
+
# All tenants
|
|
183
|
+
GET /orders
|
|
184
|
+
|
|
185
|
+
# One tenant
|
|
186
|
+
GET /orders?companyId=42
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
No special header or query parameter needed — the normal filter API handles it.
|
|
190
|
+
|
|
191
|
+
#### Per-resource bypass override
|
|
192
|
+
|
|
193
|
+
Override the global `bypassRoles` for a single resource — or disable bypass entirely for sensitive models:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
const resources = [
|
|
197
|
+
{
|
|
198
|
+
routePrefix: 'orders',
|
|
199
|
+
repository: orderRepo,
|
|
200
|
+
// inherits bypassRoles from TenantOptions
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
routePrefix: 'payment-methods',
|
|
204
|
+
repository: paymentRepo,
|
|
205
|
+
bypassTenantRoles: [], // no one bypasses tenant on this resource
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
routePrefix: 'users',
|
|
209
|
+
repository: userRepo,
|
|
210
|
+
bypassTenantRoles: ['super_admin'], // only super_admin, not support:read-all
|
|
211
|
+
}
|
|
212
|
+
]
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Query examples
|
|
218
|
+
|
|
219
|
+
### Fetch one record
|
|
220
|
+
|
|
221
|
+
```graphql
|
|
222
|
+
query {
|
|
223
|
+
getUser(id: "42") {
|
|
224
|
+
id
|
|
225
|
+
name
|
|
226
|
+
email
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Paginated list with filters
|
|
232
|
+
|
|
233
|
+
```graphql
|
|
234
|
+
query {
|
|
235
|
+
listUsers(
|
|
236
|
+
filter: { status: "active" }
|
|
237
|
+
limit: 20
|
|
238
|
+
offset: 0
|
|
239
|
+
orderBy: [{ field: "createdAt", direction: desc }]
|
|
240
|
+
) {
|
|
241
|
+
count
|
|
242
|
+
results {
|
|
243
|
+
id
|
|
244
|
+
name
|
|
245
|
+
email
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Advanced query (full filter expressions)
|
|
252
|
+
|
|
253
|
+
```graphql
|
|
254
|
+
query {
|
|
255
|
+
queryUsers(
|
|
256
|
+
where: [
|
|
257
|
+
{ field: "status", comparison: "IN", value1: ["active", "trial"] },
|
|
258
|
+
{ operator: "AND" },
|
|
259
|
+
{ field: "createdAt", comparison: ">=", value1: "2025-01-01" }
|
|
260
|
+
]
|
|
261
|
+
orderBy: [{ field: "name", direction: asc }]
|
|
262
|
+
limit: 50
|
|
263
|
+
) {
|
|
264
|
+
count
|
|
265
|
+
results {
|
|
266
|
+
id
|
|
267
|
+
name
|
|
268
|
+
email
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### With variables
|
|
275
|
+
|
|
276
|
+
```graphql
|
|
277
|
+
query ListActiveUsers($status: String!, $limit: Int) {
|
|
278
|
+
listUsers(filter: { status: $status }, limit: $limit) {
|
|
279
|
+
count
|
|
280
|
+
results { id name }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Variables payload:
|
|
286
|
+
```json
|
|
287
|
+
{ "status": "active", "limit": 10 }
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## Mutation examples
|
|
293
|
+
|
|
294
|
+
### Create
|
|
295
|
+
|
|
296
|
+
```graphql
|
|
297
|
+
mutation {
|
|
298
|
+
createUser(input: { name: "Alice", email: "alice@example.com" }) {
|
|
299
|
+
id
|
|
300
|
+
name
|
|
301
|
+
email
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Partial update
|
|
307
|
+
|
|
308
|
+
```graphql
|
|
309
|
+
mutation {
|
|
310
|
+
updateUser(id: "42", input: { name: "Alice Smith" }) {
|
|
311
|
+
id
|
|
312
|
+
name
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### Bulk update
|
|
318
|
+
|
|
319
|
+
```graphql
|
|
320
|
+
mutation {
|
|
321
|
+
updateManyUser(
|
|
322
|
+
where: [{ field: "status", comparison: "=", value1: "trial" }]
|
|
323
|
+
update: { status: "active" }
|
|
324
|
+
) {
|
|
325
|
+
updated
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Delete
|
|
331
|
+
|
|
332
|
+
```graphql
|
|
333
|
+
mutation {
|
|
334
|
+
deleteUser(id: "42")
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## Types reference
|
|
341
|
+
|
|
342
|
+
| Type | Description |
|
|
343
|
+
|---|---|
|
|
344
|
+
| `GraphQLOptions` | Configuration object passed to `registerCrudApi` / `createExpressCrudRouter` |
|
|
345
|
+
| `GraphQLResourceContext` | Internal per-resource context (available if building custom integrations) |
|
|
346
|
+
| `GraphQLResolverContext` | The `contextValue` available in every resolver: `{ req: HttpRequest }` |
|
|
347
|
+
|
|
348
|
+
All types are re-exported from `@edium/halifax`:
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
import type { GraphQLOptions, GraphQLResourceContext, GraphQLResolverContext } from '@edium/halifax'
|
|
352
|
+
```
|
package/README_INTERFACES.md
CHANGED
|
@@ -24,9 +24,11 @@ Full definition of a Halifax resource. Pass an array of these to `createExpressC
|
|
|
24
24
|
| `requiredPermissions` | `Partial<Record<CrudAction, string[]>>` | no | Permission strings per action checked by the auth strategy. |
|
|
25
25
|
| `defaultLimit` | `number` | no | Default page size when caller omits `?limit=`. Defaults to `DEFAULT_PAGE_LIMIT` (5000). `0` disables the default bound. |
|
|
26
26
|
| `maxLimit` | `number` | no | Hard cap on page size. Defaults to `MAX_PAGE_LIMIT` (5000). `0` removes the cap. |
|
|
27
|
-
| `maxFilterDepth` | `number` | no | Maximum nesting depth for `where` clause children. Defaults to
|
|
27
|
+
| `maxFilterDepth` | `number` | no | Maximum nesting depth for `where` clause children. Defaults to 4. |
|
|
28
28
|
| `cache` | `ResourceCacheConfig \| false` | no | Per-resource cache TTL. `false` disables caching even when an API-wide default is set. |
|
|
29
29
|
| `envelope` | `string \| null` | no | Wraps every success response body under this key (e.g. `'data'`). Overrides the API-wide `envelope` option. |
|
|
30
|
+
| `graphql` | `boolean` | no | When `false`, excludes this resource from the auto-generated GraphQL schema while keeping it on REST. Defaults to `true` when GraphQL is enabled. |
|
|
31
|
+
| `bypassTenantRoles` | `string[]` | no | Roles or permission slugs whose holders bypass tenant scoping for read operations on this resource. Overrides `TenantOptions.bypassRoles`. Set to `[]` to prevent bypass on this resource even when a global list is configured. |
|
|
30
32
|
|
|
31
33
|
---
|
|
32
34
|
|
|
@@ -232,9 +234,10 @@ Configures multi-tenant isolation API-wide. Set on `CrudApiOptions.tenant`.
|
|
|
232
234
|
|
|
233
235
|
| Property | Type | Default | Description |
|
|
234
236
|
| ----------- | ------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
235
|
-
| `resolveId`
|
|
236
|
-
| `field`
|
|
237
|
-
| `strict`
|
|
237
|
+
| `resolveId` | `(ctx: TenantResolveContext) => unknown \| Promise<unknown>` | required | Resolves the tenant key from the auth context and request. Must come from the token/session — never from client input. Return `null`/`undefined` when none applies. |
|
|
238
|
+
| `field` | `string` | `'tenantId'` | Default column name for auto-detection: resources with a field of this name are automatically scoped on it. |
|
|
239
|
+
| `strict` | `boolean` | `true` | When `true`, a tenant-scoped resource whose `resolveId` returns no value rejects with 403 rather than serving unscoped. |
|
|
240
|
+
| `bypassRoles` | `string[]` | — | Roles or permission slugs (matched against `auth.roles` OR `auth.permissions`) whose holders receive unscoped reads across all tenants. Writes are never bypassed. Per-resource `ResourceDefinition.bypassTenantRoles` takes precedence. |
|
|
238
241
|
|
|
239
242
|
---
|
|
240
243
|
|
|
@@ -404,11 +407,12 @@ Import: `@edium/halifax`
|
|
|
404
407
|
|
|
405
408
|
Contract for pluggable cache backends. Implement this to use Redis, Memcached, or any other store.
|
|
406
409
|
|
|
407
|
-
| Method
|
|
408
|
-
|
|
|
409
|
-
| `get`
|
|
410
|
-
| `set`
|
|
411
|
-
| `delete`
|
|
410
|
+
| Method | Signature | Description |
|
|
411
|
+
| ----------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
412
|
+
| `get` | `(key: string) => Promise<unknown> \| unknown` | Read a cached value. Returns `undefined` when absent or expired. |
|
|
413
|
+
| `set` | `(key, value, ttlSeconds?) => Promise<void> \| void` | Write a value. `ttlSeconds` `0` or omitted means no expiry. |
|
|
414
|
+
| `delete` | `(key: string) => Promise<void> \| void` | Delete a cached value. |
|
|
415
|
+
| `increment` | `(key: string) => Promise<number> \| number` *(opt.)* | Atomically increment an integer key and return the new value. Implement this for safe concurrent version bumps (e.g. Redis `INCR`). When omitted, Halifax falls back to a non-atomic `get`+`set`. |
|
|
412
416
|
|
|
413
417
|
---
|
|
414
418
|
|
|
@@ -433,11 +437,12 @@ Import: `@edium/halifax`
|
|
|
433
437
|
|
|
434
438
|
Minimal structural type for a Redis client. `RedisCacheStore` uses this interface so no specific Redis package is a hard dependency. `redis` v4's `get`, `set`, and `del` satisfy it directly.
|
|
435
439
|
|
|
436
|
-
| Method | Signature
|
|
437
|
-
| ------ |
|
|
438
|
-
| `get` | `(key: string) => Promise<string \| null>`
|
|
439
|
-
| `set` | `(key, value, options?) => Promise<unknown>`
|
|
440
|
-
| `del` | `(key: string) => Promise<unknown>`
|
|
440
|
+
| Method | Signature | Description |
|
|
441
|
+
| ------ | -------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
442
|
+
| `get` | `(key: string) => Promise<string \| null>` | Read a key. |
|
|
443
|
+
| `set` | `(key, value, options?) => Promise<unknown>` | Write a key. `options.EX` sets TTL in seconds. |
|
|
444
|
+
| `del` | `(key: string) => Promise<unknown>` | Delete a key. |
|
|
445
|
+
| `incr` | `(key: string) => Promise<number>` *(optional)* | Atomically increment a key. Used by `RedisCacheStore.increment` for version bumps when the client exposes this method. |
|
|
441
446
|
|
|
442
447
|
---
|
|
443
448
|
|
package/README_MULTITENANCY.md
CHANGED
|
@@ -84,6 +84,11 @@ interface TenantOptions {
|
|
|
84
84
|
field?: string
|
|
85
85
|
/** Fail-closed when no tenant resolves. Defaults to true. */
|
|
86
86
|
strict?: boolean
|
|
87
|
+
/**
|
|
88
|
+
* Roles or permission slugs whose holders bypass tenant scoping on read operations.
|
|
89
|
+
* See "Admin bypass" below.
|
|
90
|
+
*/
|
|
91
|
+
bypassRoles?: string[]
|
|
87
92
|
}
|
|
88
93
|
```
|
|
89
94
|
|
|
@@ -113,6 +118,88 @@ createPrismaResources(prisma, models, {
|
|
|
113
118
|
})
|
|
114
119
|
```
|
|
115
120
|
|
|
121
|
+
## Admin bypass
|
|
122
|
+
|
|
123
|
+
Sometimes a super-admin or support role needs to read across all tenants — for reporting,
|
|
124
|
+
debugging, or backoffice tooling — without being confined to a single company's data.
|
|
125
|
+
|
|
126
|
+
### Enabling bypass
|
|
127
|
+
|
|
128
|
+
Add `bypassRoles` to `TenantOptions`. Any caller whose `auth.roles` **or** `auth.permissions`
|
|
129
|
+
matches at least one entry is granted an unscoped read:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
createExpressCrudRouter(resources, {
|
|
133
|
+
tenant: {
|
|
134
|
+
resolveId: ({ auth }) => auth.claims?.companyId,
|
|
135
|
+
bypassRoles: ['super_admin', 'support:read-all'],
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Values are matched against both `auth.roles` and `auth.permissions` — either paradigm works.
|
|
141
|
+
|
|
142
|
+
### What bypass does (and does not do)
|
|
143
|
+
|
|
144
|
+
| Operation | Behaviour with bypass |
|
|
145
|
+
| --- | --- |
|
|
146
|
+
| `GET /resource` — list | Returns rows from **all** tenants (unscoped) |
|
|
147
|
+
| `GET /resource/:id` | Returns the record regardless of which tenant owns it |
|
|
148
|
+
| `POST /resource/query` (query builder) | Returns rows from all tenants |
|
|
149
|
+
| `POST /resource` (create) | **Not bypassed** — tenant value still comes from `resolveId` |
|
|
150
|
+
| `PATCH`, `PUT`, `DELETE` | **Not bypassed** — tenant value still comes from `resolveId` |
|
|
151
|
+
|
|
152
|
+
Writes are deliberately excluded. Tenant on writes comes from the authenticated session —
|
|
153
|
+
never from the client and never from the bypass path. An admin writing through the API either has a `companyId` in their token (and writes to that company) or receives 403.
|
|
154
|
+
|
|
155
|
+
### Narrowing a bypass read to one tenant
|
|
156
|
+
|
|
157
|
+
When a super-admin wants data for a specific company they use the **normal filter mechanism**
|
|
158
|
+
— the tenant field is just another filterable column from their perspective. No special header or query parameter is needed:
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
# All tenants (bypass active, no filter)
|
|
162
|
+
GET /orders
|
|
163
|
+
|
|
164
|
+
# One specific tenant
|
|
165
|
+
GET /orders?companyId=42
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
In GraphQL:
|
|
169
|
+
|
|
170
|
+
```graphql
|
|
171
|
+
# All tenants
|
|
172
|
+
{ listOrders { count results { id companyId total } } }
|
|
173
|
+
|
|
174
|
+
# One tenant
|
|
175
|
+
{ listOrders(filter: { companyId: 42 }) { count results { id companyId total } } }
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Per-resource bypass override
|
|
179
|
+
|
|
180
|
+
`ResourceDefinition.bypassTenantRoles` overrides the global `bypassRoles` for one resource.
|
|
181
|
+
Set it to `[]` to prevent bypass on a particularly sensitive model:
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
const resources = [
|
|
185
|
+
{
|
|
186
|
+
routePrefix: 'orders',
|
|
187
|
+
repository: orderRepo,
|
|
188
|
+
// inherits bypassRoles from TenantOptions → super_admin gets all
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
routePrefix: 'payment-methods',
|
|
192
|
+
repository: paymentRepo,
|
|
193
|
+
bypassTenantRoles: [], // always scoped — even super_admin sees only their tenant
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
routePrefix: 'users',
|
|
197
|
+
repository: userRepo,
|
|
198
|
+
bypassTenantRoles: ['super_admin'], // only super_admin bypasses; support:read-all does not
|
|
199
|
+
}
|
|
200
|
+
]
|
|
201
|
+
```
|
|
202
|
+
|
|
116
203
|
## Security model
|
|
117
204
|
|
|
118
205
|
Tenant scoping is designed to **fail closed**. The guarantees:
|
package/README_OPENAPI.md
CHANGED
|
@@ -240,7 +240,7 @@ The body combines `QueryOptions` fields (to select records) with an `update` key
|
|
|
240
240
|
|
|
241
241
|
```json
|
|
242
242
|
{
|
|
243
|
-
"where": [{ "field": "status", "comparison": "=", "
|
|
243
|
+
"where": [{ "field": "status", "comparison": "=", "value1": "draft" }],
|
|
244
244
|
"update": { "status": "archived" }
|
|
245
245
|
}
|
|
246
246
|
```
|
|
@@ -274,17 +274,17 @@ The query builder endpoint accepts a `QueryOptions` body for full-featured filte
|
|
|
274
274
|
```json
|
|
275
275
|
{
|
|
276
276
|
"where": [
|
|
277
|
-
{ "field": "published", "comparison": "=", "
|
|
278
|
-
{ "field": "createdAt", "comparison": ">=", "
|
|
277
|
+
{ "field": "published", "comparison": "=", "value1": true },
|
|
278
|
+
{ "field": "createdAt", "comparison": ">=", "value1": "2024-01-01T00:00:00Z" },
|
|
279
279
|
{
|
|
280
280
|
"operator": "OR",
|
|
281
281
|
"children": [
|
|
282
|
-
{ "field": "title", "comparison": "CONTAINS", "
|
|
283
|
-
{ "field": "title", "comparison": "STARTS WITH", "
|
|
282
|
+
{ "field": "title", "comparison": "CONTAINS", "value1": "Halifax" },
|
|
283
|
+
{ "field": "title", "comparison": "STARTS WITH", "value1": "Hello" }
|
|
284
284
|
]
|
|
285
285
|
}
|
|
286
286
|
],
|
|
287
|
-
"orderBy": [{ "field": "createdAt", "
|
|
287
|
+
"orderBy": [{ "field": "createdAt", "order": "DESC" }],
|
|
288
288
|
"limit": 20,
|
|
289
289
|
"offset": 0,
|
|
290
290
|
"fields": ["id", "title", "createdAt"],
|
|
@@ -318,12 +318,12 @@ Flat `where` arrays use **AND-precedence over OR** (same as SQL). Use `children`
|
|
|
318
318
|
```json
|
|
319
319
|
{
|
|
320
320
|
"where": [
|
|
321
|
-
{ "field": "active", "comparison": "=", "
|
|
321
|
+
{ "field": "active", "comparison": "=", "value1": true },
|
|
322
322
|
{
|
|
323
323
|
"operator": "OR",
|
|
324
324
|
"children": [
|
|
325
|
-
{ "field": "role", "comparison": "=", "
|
|
326
|
-
{ "field": "role", "comparison": "=", "
|
|
325
|
+
{ "field": "role", "comparison": "=", "value1": "admin" },
|
|
326
|
+
{ "field": "role", "comparison": "=", "value1": "moderator" }
|
|
327
327
|
]
|
|
328
328
|
}
|
|
329
329
|
]
|
package/README_REPO_ADAPTERS.md
CHANGED
|
@@ -186,6 +186,8 @@ A resource always needs a field schema — it's the allow-list that powers filte
|
|
|
186
186
|
|
|
187
187
|
Halifax's `peerDependencies` allow `@prisma/client >=6.0.0`, so it runs on **Prisma 6 or Prisma 7**. `PrismaAdapter` is database- and version-agnostic: it imports nothing from `@prisma/client` and only calls standard model-delegate methods (`findMany`, `findUnique`, `findFirst`, `create`, `createMany`, `update`, `updateMany`, `delete`, `deleteMany`, `upsert`, `count`) that behave identically across both majors. **You** construct the client and pass `prisma.<model>` as the `delegate` — Halifax never touches the parts that differ between the versions.
|
|
188
188
|
|
|
189
|
+
**Tenant-scoped paths require `updateMany` and `deleteMany` on the delegate.** When multi-tenant isolation is active, `updateOne` uses `updateMany(scopedWhere)` for an atomic ownership-enforced write, and `deleteOne` uses `deleteMany(scopedWhere)` for the same reason. If the delegate does not expose these methods, both operations throw `ServerError`. Standard Prisma delegates always expose them; this only affects non-standard or mock delegates.
|
|
190
|
+
|
|
189
191
|
> **Caveats.** Halifax's CI matrix exercises **Prisma 7 only** — Prisma 6 is supported on the strength of that stable delegate surface, not a dedicated CI leg, so treat it as best-effort and pin/test your own app against it. Prisma 7 is the recommended path; the main reason to stay on (or drop to) Prisma 6 today is **MongoDB**, which Prisma 7 does not yet support. When Prisma 7 restores MongoDB, prefer upgrading over remaining on 6.
|
|
190
192
|
|
|
191
193
|
What you implement differently on Prisma 6 (everything below is your project's Prisma setup — no Halifax code changes):
|
|
@@ -306,6 +308,14 @@ new DrizzleAdapter(db, table, config?, scope?)
|
|
|
306
308
|
| `config.idField` | `string` (optional) | Primary key field name. Defaults to auto-detecting the first column marked `.primaryKey()`. Set explicitly for composite PKs or non-standard names. |
|
|
307
309
|
| `scope` | `TenantScope \| null` | Tenant scope. Set by `withScope()` internally — do not pass directly. |
|
|
308
310
|
|
|
311
|
+
### Relation includes
|
|
312
|
+
|
|
313
|
+
`DrizzleAdapter` does **not** support `?include=` (relation eager-loading). It reports
|
|
314
|
+
`capabilities.supportsIncludes: false`, so the router rejects `?include=` requests with
|
|
315
|
+
`422 Unprocessable Entity` rather than silently returning records with no related data.
|
|
316
|
+
If you need related records, fetch them with a second query or use `PrismaAdapter` for
|
|
317
|
+
the resource that requires includes.
|
|
318
|
+
|
|
309
319
|
### Multi-tenancy
|
|
310
320
|
|
|
311
321
|
`DrizzleAdapter` supports per-resource tenant scoping via `withScope()` exactly like `PrismaAdapter`. See [README_MULTITENANCY.md](./README_MULTITENANCY.md) for how to configure it on the resource.
|
|
@@ -88,6 +88,11 @@ export declare class DrizzleAdapter<TRecord = Record<string, unknown>, TCreate =
|
|
|
88
88
|
private readonly table;
|
|
89
89
|
readonly fields: FieldDefinition[];
|
|
90
90
|
readonly idField: string;
|
|
91
|
+
/** Drizzle uses `.returning()` for inserts/updates, so it always returns created records. */
|
|
92
|
+
readonly capabilities: {
|
|
93
|
+
supportsIncludes: boolean;
|
|
94
|
+
supportsCreateManyReturn: boolean;
|
|
95
|
+
};
|
|
91
96
|
private readonly columns;
|
|
92
97
|
private readonly scope;
|
|
93
98
|
constructor(db: AnyDrizzleDB, table: Table, config?: DrizzleAdapterConfig, scope?: TenantScope | null);
|
|
@@ -68,6 +68,8 @@ export class DrizzleAdapter {
|
|
|
68
68
|
table;
|
|
69
69
|
fields;
|
|
70
70
|
idField;
|
|
71
|
+
/** Drizzle uses `.returning()` for inserts/updates, so it always returns created records. */
|
|
72
|
+
capabilities = { supportsIncludes: false, supportsCreateManyReturn: true };
|
|
71
73
|
columns;
|
|
72
74
|
scope;
|
|
73
75
|
constructor(db, table, config = {}, scope = null) {
|
|
@@ -237,6 +239,12 @@ export class DrizzleAdapter {
|
|
|
237
239
|
}
|
|
238
240
|
}
|
|
239
241
|
async upsertOne(id, data) {
|
|
242
|
+
// Non-atomic: the getOne check and the subsequent write are separate statements.
|
|
243
|
+
// Under concurrent load, two simultaneous upserts for the same absent ID can both
|
|
244
|
+
// pass the getOne check and then race on createOne — the loser gets a ConflictError.
|
|
245
|
+
// Drizzle has no single portable INSERT…ON CONFLICT across all databases, so this
|
|
246
|
+
// is the safest cross-provider implementation. Callers that need true atomicity
|
|
247
|
+
// should implement a custom repository using a database-specific ON CONFLICT clause.
|
|
240
248
|
const existing = await this.getOne(id);
|
|
241
249
|
if (existing) {
|
|
242
250
|
const updated = await this.updateOne(id, data);
|
|
@@ -285,6 +293,6 @@ export class DrizzleAdapter {
|
|
|
285
293
|
return { count: total, results: rows };
|
|
286
294
|
}
|
|
287
295
|
withScope(scope) {
|
|
288
|
-
return new DrizzleAdapter(this.db, this.table, { idField: this.idField }, scope
|
|
296
|
+
return new DrizzleAdapter(this.db, this.table, { idField: this.idField }, scope);
|
|
289
297
|
}
|
|
290
298
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { IQueryOptions } from '@edium/halifax-types';
|
|
2
|
-
import type {
|
|
3
|
-
import type { FieldDefinition, RelationDefinition, ModelSchema } from '../../../core/types.js';
|
|
2
|
+
import type { DeleteManyResult, FieldDefinition, ListOptions, ListResult, ModelSchema, QueryResult, RelationDefinition, Repository, RepositoryCapabilities, TenantScope, UpdateManyResult } from '../../../core/types.js';
|
|
4
3
|
import type { PrismaAdapterOptions } from './types.js';
|
|
5
4
|
/**
|
|
6
5
|
* PrismaAdapter is a generic repository implementation that uses Prisma delegates to perform
|