@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/README.md +6 -3
  3. package/README_AUTH.md +2 -2
  4. package/README_AUTOCRUD.md +5 -4
  5. package/README_CACHE.md +6 -0
  6. package/README_CLASSES.md +13 -6
  7. package/README_GRAPHQL.md +352 -0
  8. package/README_INTERFACES.md +19 -14
  9. package/README_MULTITENANCY.md +87 -0
  10. package/README_OPENAPI.md +9 -9
  11. package/README_REPO_ADAPTERS.md +10 -0
  12. package/dist/adapters/orm/drizzle/DrizzleAdapter.d.ts +5 -0
  13. package/dist/adapters/orm/drizzle/DrizzleAdapter.js +9 -1
  14. package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +1 -2
  15. package/dist/adapters/orm/prisma/PrismaAdapter.js +106 -34
  16. package/dist/adapters/orm/prisma/astToPrisma.js +6 -0
  17. package/dist/auth/strategies/JwtClaimsAuthStrategy.js +2 -8
  18. package/dist/auth/strategies/PassportStrategies.js +3 -9
  19. package/dist/auth/strategies/types.d.ts +7 -0
  20. package/dist/auth/strategies/types.js +13 -1
  21. package/dist/core/cache/CacheStore.d.ts +12 -0
  22. package/dist/core/cache/createCachingRepository.js +10 -1
  23. package/dist/core/cache/in-memory/InMemoryCacheStore.d.ts +14 -1
  24. package/dist/core/cache/in-memory/InMemoryCacheStore.js +29 -1
  25. package/dist/core/cache/redis/RedisCacheStore.d.ts +6 -0
  26. package/dist/core/cache/redis/RedisCacheStore.js +14 -0
  27. package/dist/core/cache/redis/RedisLikeClient.d.ts +2 -0
  28. package/dist/core/crudRouter.d.ts +38 -0
  29. package/dist/core/crudRouter.js +55 -21
  30. package/dist/core/fields.d.ts +11 -1
  31. package/dist/core/fields.js +19 -0
  32. package/dist/core/handlerUtils.d.ts +7 -1
  33. package/dist/core/handlerUtils.js +15 -11
  34. package/dist/core/handlers/create.js +4 -3
  35. package/dist/core/handlers/deleteMany.js +1 -1
  36. package/dist/core/handlers/deleteOne.js +1 -1
  37. package/dist/core/handlers/query.js +4 -6
  38. package/dist/core/handlers/readMany.js +4 -6
  39. package/dist/core/handlers/readOne.js +4 -7
  40. package/dist/core/handlers/updateMany.js +4 -5
  41. package/dist/core/handlers/updateOne.js +1 -1
  42. package/dist/core/handlers/upsertOne.js +1 -1
  43. package/dist/core/queryString.d.ts +10 -0
  44. package/dist/core/queryString.js +23 -0
  45. package/dist/core/types.d.ts +22 -0
  46. package/dist/core/validation.js +5 -11
  47. package/dist/graphql/graphiql.d.ts +5 -0
  48. package/dist/graphql/graphiql.js +29 -0
  49. package/dist/graphql/index.d.ts +4 -0
  50. package/dist/graphql/index.js +3 -0
  51. package/dist/graphql/registerGraphqlRoute.d.ts +10 -0
  52. package/dist/graphql/registerGraphqlRoute.js +79 -0
  53. package/dist/graphql/scalars.d.ts +6 -0
  54. package/dist/graphql/scalars.js +32 -0
  55. package/dist/graphql/schema.d.ts +3 -0
  56. package/dist/graphql/schema.js +635 -0
  57. package/dist/graphql/types.d.ts +48 -0
  58. package/dist/graphql/types.js +1 -0
  59. package/dist/index.d.ts +1 -0
  60. package/dist/openapi/specGenerator.js +19 -19
  61. 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
+ ```
@@ -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 3. |
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` | `(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. |
236
- | `field` | `string` | `'tenantId'` | Default column name for auto-detection: resources with a field of this name are automatically scoped on it. |
237
- | `strict` | `boolean` | `true` | When `true`, a tenant-scoped resource whose `resolveId` returns no value rejects with 403 rather than serving unscoped. |
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 | Signature | Description |
408
- | -------- | ---------------------------------------------------- | ---------------------------------------------------------------- |
409
- | `get` | `(key: string) => Promise<unknown> \| unknown` | Read a cached value. Returns `undefined` when absent or expired. |
410
- | `set` | `(key, value, ttlSeconds?) => Promise<void> \| void` | Write a value. `ttlSeconds` `0` or omitted means no expiry. |
411
- | `delete` | `(key: string) => Promise<void> \| void` | Delete a cached value. |
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 | Description |
437
- | ------ | -------------------------------------------- | ---------------------------------------------- |
438
- | `get` | `(key: string) => Promise<string \| null>` | Read a key. |
439
- | `set` | `(key, value, options?) => Promise<unknown>` | Write a key. `options.EX` sets TTL in seconds. |
440
- | `del` | `(key: string) => Promise<unknown>` | Delete a key. |
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
 
@@ -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": "=", "value": "draft" }],
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": "=", "value": true },
278
- { "field": "createdAt", "comparison": ">=", "value": "2024-01-01T00:00:00Z" },
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", "value": "Halifax" },
283
- { "field": "title", "comparison": "STARTS WITH", "value": "Hello" }
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", "direction": "desc" }],
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": "=", "value": true },
321
+ { "field": "active", "comparison": "=", "value1": true },
322
322
  {
323
323
  "operator": "OR",
324
324
  "children": [
325
- { "field": "role", "comparison": "=", "value": "admin" },
326
- { "field": "role", "comparison": "=", "value": "moderator" }
325
+ { "field": "role", "comparison": "=", "value1": "admin" },
326
+ { "field": "role", "comparison": "=", "value1": "moderator" }
327
327
  ]
328
328
  }
329
329
  ]
@@ -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 ?? null);
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 { Repository, RepositoryCapabilities, DeleteManyResult, ListOptions, ListResult, QueryResult, TenantScope, UpdateManyResult } from '../../../core/types.js';
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