@edium/halifax 2.2.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +161 -1
  2. package/README.md +15 -15
  3. package/README_AUTH.md +2 -2
  4. package/README_AUTOCRUD.md +5 -4
  5. package/README_CACHE.md +6 -0
  6. package/README_CLASSES.md +13 -6
  7. package/README_INTERFACES.md +13 -11
  8. package/README_OPENAPI.md +1 -1
  9. package/README_REPO_ADAPTERS.md +10 -0
  10. package/dist/adapters/orm/drizzle/DrizzleAdapter.d.ts +5 -0
  11. package/dist/adapters/orm/drizzle/DrizzleAdapter.js +57 -14
  12. package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +1 -2
  13. package/dist/adapters/orm/prisma/PrismaAdapter.js +149 -39
  14. package/dist/adapters/orm/prisma/astToPrisma.js +6 -0
  15. package/dist/auth/strategies/JwtClaimsAuthStrategy.js +2 -8
  16. package/dist/auth/strategies/PassportStrategies.js +3 -9
  17. package/dist/auth/strategies/types.d.ts +7 -0
  18. package/dist/auth/strategies/types.js +13 -1
  19. package/dist/core/cache/CacheStore.d.ts +12 -0
  20. package/dist/core/cache/createCachingRepository.js +10 -1
  21. package/dist/core/cache/in-memory/InMemoryCacheStore.d.ts +14 -1
  22. package/dist/core/cache/in-memory/InMemoryCacheStore.js +29 -1
  23. package/dist/core/cache/redis/RedisCacheStore.d.ts +6 -0
  24. package/dist/core/cache/redis/RedisCacheStore.js +14 -0
  25. package/dist/core/cache/redis/RedisLikeClient.d.ts +2 -0
  26. package/dist/core/crudRouter.js +2 -20
  27. package/dist/core/fields.d.ts +11 -1
  28. package/dist/core/fields.js +19 -0
  29. package/dist/core/handlerUtils.d.ts +6 -0
  30. package/dist/core/handlerUtils.js +16 -11
  31. package/dist/core/handlers/create.js +3 -2
  32. package/dist/core/handlers/query.js +3 -5
  33. package/dist/core/handlers/readMany.js +3 -5
  34. package/dist/core/handlers/readOne.js +3 -6
  35. package/dist/core/handlers/updateMany.js +3 -4
  36. package/dist/core/queryString.d.ts +10 -0
  37. package/dist/core/queryString.js +23 -0
  38. package/dist/core/validation.js +5 -11
  39. package/dist/errors/ConflictError.d.ts +5 -0
  40. package/dist/errors/ConflictError.js +8 -0
  41. package/dist/index.d.ts +1 -0
  42. package/dist/index.js +1 -0
  43. package/dist/openapi/specGenerator.js +24 -19
  44. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -3,12 +3,172 @@
3
3
  All notable changes to this project are documented here. This project adheres to
4
4
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [2.3.0]
7
+
8
+ ### Security
9
+
10
+ - **`PrismaAdapter.upsertOne` cross-tenant write vulnerability eliminated** — the previous scoped
11
+ upsert path performed `findFirst({ where: { id } })` without a tenant filter, then called
12
+ `delegate.upsert({ where: { id } })` also without a tenant filter. The application-level
13
+ ownership check between the two statements created a TOCTOU window: if another tenant's record
14
+ appeared at that ID between the check and the upsert, Prisma's `upsert` would fire its `update`
15
+ branch and write into that other tenant's row. The scoped path no longer uses `delegate.upsert`.
16
+ It now uses a scoped `findFirst(scopedWhere)` to detect existence, `updateMany(scopedWhere)` for
17
+ the update branch (atomic, same fix applied to `updateOne`), and `create(stampTenant)` for the
18
+ create branch. The `delegate.upsert` call is retained only for the unscoped path.
19
+
20
+ - **`PrismaAdapter.updateOne` and `deleteOne` unsafe fallbacks removed** — when `updateMany`
21
+ (for `updateOne`) or `deleteMany` (for `deleteOne`) were unavailable on the delegate, the code
22
+ fell back to a two-step scoped-check + unscoped-write sequence. Both halves now throw
23
+ `ServerError` immediately rather than proceeding with an unsafe path. Standard Prisma delegates
24
+ always expose both methods, so this only affects non-standard or mock delegates.
25
+
26
+ - **`requiredPermissions` now uses OR semantics consistently** — all three auth strategies
27
+ (`JwtClaimsAuthStrategy`, `PassportJwtStrategy`, `PassportSessionStrategy`) previously used
28
+ `.every()` (ALL permissions required), while the fallback path in `handlerUtils.ts` correctly
29
+ used `.some()` (ANY single permission grants access). The strategies were wrong. All four paths
30
+ now delegate to a shared `checkRequiredPermissions()` utility that applies `.some()` semantics:
31
+ a caller who holds *any one* of the listed permissions is authorized. This matches the documented
32
+ behavior of `readRoles`/`writeRoles` at the field level and is a consistent read of "required
33
+ permissions" as an OR list. **If you relied on the undocumented ALL-must-match behavior,
34
+ tighten your permission model accordingly.**
35
+
36
+ - **TOCTOU race in `PrismaAdapter.updateOne` under tenant scope eliminated** — the previous
37
+ scoped-update path did a `findFirst` ownership check and then a separate `update` call. Between
38
+ the two statements, a concurrent request could transfer the record to a different tenant,
39
+ allowing the update to land on a record the caller no longer owns. The fix uses
40
+ `updateMany({ where: scopedWhere })` as the primary path when the Prisma delegate supports it —
41
+ the tenant field is enforced atomically in a single SQL statement. A safe `findFirst`-then-update
42
+ fallback remains only for delegates that lack `updateMany`.
43
+
44
+ ### Performance
45
+
46
+ - **O(n²) → O(n) field validation** — `validateSelectableFields` and `validateSortableFields`
47
+ previously called `.find()` inside a `.filter()`, giving O(n²) complexity per request. Both now
48
+ pre-build a `Map` from field names to definitions and do O(1) lookups in the filter pass.
49
+
50
+ - **`makeReadableFieldFilter` factory for bulk-response field stripping** — `filterReadableFields`
51
+ previously rebuilt the `fieldMap` `Map` and `userRoles` `Set` for every record in a batch. A
52
+ new exported factory `makeReadableFieldFilter(resource, auth)` builds both structures once and
53
+ returns a reusable `(record) => record` function. The `readMany`, `query`, and `updateMany`
54
+ handlers now call the factory once before `.map()` and pass the compiled function directly,
55
+ eliminating O(n) redundant Map constructions for responses with up to 5000 records.
56
+
57
+ - **`parseGetOneOptions` for lean GET /:id parsing** — `readOne` previously called
58
+ `parseListOptions`, which parses and validates `?fields=`, `?include=`, `?limit=`, `?offset=`,
59
+ `?order=`, and all filter fields — all of which are irrelevant to a single-record GET. A new
60
+ exported `parseGetOneOptions(query, resource)` in `queryString.ts` only parses and validates
61
+ `?fields=` and `?include=`, removing the wasted work and the silent discard of inapplicable
62
+ parameters on that route.
63
+
64
+ ### Fixed
65
+
66
+ - **OpenAPI `distinct` type corrected** — the query-builder endpoint's `distinct` parameter was
67
+ documented as `{ type: 'boolean' }` in the generated spec. The actual type is `string[]` (an
68
+ array of field names to de-duplicate on). The spec now documents it as
69
+ `{ type: 'array', items: { type: 'string' } }` with an accurate description.
70
+
71
+ - **`normalizeEnvelope` and `mergeRelationDefinitions` extracted to `fields.ts`** — both
72
+ utilities existed in identical form in `crudRouter.ts` and `specGenerator.ts`. They are now
73
+ exported from `src/core/fields.ts` alongside `mergeFieldDefinitions`, and both files import
74
+ from there. Behavior is unchanged.
75
+
76
+ - **`checkRequiredPermissions` extracted to `src/auth/strategies/types.ts`** — the permission
77
+ check logic was duplicated (with incompatible `.every()` vs `.some()` semantics) across all
78
+ three strategy files and `handlerUtils.ts`. It is now a single exported function and all four
79
+ call sites use it. See the Security section above for the semantics fix.
80
+
81
+ - **`DrizzleAdapter` reports `supportsIncludes: false`** — the adapter has always silently ignored
82
+ `?include=` requests because Drizzle has no built-in relation eager-loading surface compatible
83
+ with Halifax's interface. The `capabilities` property now explicitly sets `supportsIncludes:
84
+ false`, which causes the router to reject `?include=` with `422 Unprocessable Entity` instead
85
+ of silently returning records with no related data.
86
+
87
+ - **`InMemoryCacheStore` memory growth bounded** — the store previously only evicted expired
88
+ entries lazily on reads, so a write-heavy workload with few reads could accumulate stale entries
89
+ without bound. The `set()` method now runs a full expired-entry sweep every 200 writes
90
+ (configurable via the `sweepEvery` constructor parameter). No external timers or additional
91
+ dependencies.
92
+
93
+ - **`CacheStore.increment` — atomic cache version bumps** — `createCachingRepository` previously
94
+ incremented the version key with a non-atomic `GET` + `SET`, creating a race window under
95
+ concurrent writes on Redis. The `CacheStore` interface gains an optional `increment(key):
96
+ Promise<number> | number` method. `RedisCacheStore` implements it with Redis `INCR` (atomic
97
+ by design). `InMemoryCacheStore` implements it synchronously (race-free in Node.js's
98
+ single-threaded event loop). `createCachingRepository` uses `store.increment` when available
99
+ and falls back to the non-atomic path only for custom stores that do not implement it.
100
+
101
+ - **`DrizzleAdapter.upsertOne` non-atomicity documented** — the method does a `getOne` check
102
+ followed by a `createOne` or `updateOne`, which is non-atomic under concurrent load. A JSDoc
103
+ comment now explains the race condition and recommends implementing a custom repository with a
104
+ database-native `INSERT … ON CONFLICT` clause when true atomicity is required.
105
+
106
+ - **`PrismaAdapter` import order** — all imports were moved above function definitions and
107
+ consolidated into a single, alphabetized block. No behavior change.
108
+
109
+ - **`parseId` dead code removed** — after `validateId(raw)` narrows `raw` to `string`, the
110
+ previous `typeof raw === 'string' ? parseInt(raw, 10) : raw` branch was unreachable. The
111
+ function now calls `parseInt(raw, 10)` directly, and the redundant type guard before the
112
+ UUID/ObjectId check is also removed.
113
+
114
+ - **`LIKE` interior-wildcard limitation documented** — `likeToPrisma` in
115
+ `src/adapters/orm/prisma/astToPrisma.ts` now carries a JSDoc comment explaining that patterns
116
+ with an interior wildcard (e.g. `'foo%bar'`) cannot be expressed via Prisma's string operators
117
+ and fall through to an exact `equals` match, not a wildcard match. Use `CONTAINS`, `STARTS
118
+ WITH`, or `ENDS WITH` comparisons instead of `LIKE` when possible.
119
+
120
+ ### Documentation
121
+
122
+ - **`QueryBuilder` mutability warning** — the class JSDoc now clearly states that all chaining
123
+ methods mutate `this` and return the same instance. A before/after code example demonstrates
124
+ the correct pattern (create a new `QueryBuilder` per branch) and the incorrect pattern (sharing
125
+ one instance and calling `.limit()` twice).
126
+
127
+ - **`andGroup` / `orGroup` API design explained** — both methods require a `field`/`comparison`/
128
+ `value1` parent condition because the Halifax query AST attaches children to a parent filter
129
+ node. The JSDoc now explains this constraint, describes what the resulting predicate looks like
130
+ (`cond AND (children)` vs `cond OR (children)`), and suggests a workaround for expressing a
131
+ pure parenthesized group with no meaningful parent condition.
132
+
133
+ - **`specGenerator` raw-vs-normalized resource note** — a comment in `generateOpenApiSpec`
134
+ explains that the function operates on raw `ResourceDefinition` objects (not the normalized
135
+ forms produced by `crudRouter.normalizeResource`) and therefore re-derives merged fields and
136
+ relations independently. This is intentional — the spec generator is also usable as a
137
+ standalone function outside the router.
138
+
139
+ ## [2.2.3]
140
+
141
+ ### Added
142
+
143
+ - **`ConflictError`** — new `HttpError` subclass with HTTP status `409`. Exported from
144
+ the main package entry-point so application code can `throw new ConflictError()` in
145
+ hooks or custom repositories and have it serialised correctly.
146
+
147
+ ### Fixed
148
+
149
+ - **409 Conflict on duplicate unique-key violations** — `PrismaAdapter` and `DrizzleAdapter`
150
+ now catch unique-constraint errors from the underlying ORM and re-throw them as
151
+ `ConflictError` (HTTP 409) instead of letting the raw ORM error propagate as an
152
+ unhandled 500.
153
+ - **Prisma**: catches `P2002` (unique constraint failed) on `createOne`, `createMany`,
154
+ `updateOne`, and both branches of `upsertOne`.
155
+ - **Drizzle**: catches PostgreSQL `23505`, MySQL `1062` / `ER_DUP_ENTRY`, and SQLite
156
+ `UNIQUE constraint failed` on `createOne`, `createMany`, and `updateOne`.
157
+ - **`statusCodeMap` now includes `409 → 'CONFLICT'`** — previously, any `HttpError`
158
+ thrown with status `409` would have been serialised with `code: "INTERNAL_ERROR"`.
159
+
160
+ ### Changed
161
+
162
+ - **OpenAPI spec** — write operations (`POST /{resource}`, `PATCH /{resource}`,
163
+ `PATCH /{resource}/{id}`, `PUT /{resource}/{id}`) now include a `409 Conflict` response
164
+ definition documenting that unique-constraint violations return this status.
165
+
6
166
  ## [2.2.2]
7
167
 
8
168
  ### Fixed
9
169
 
10
170
  - Removed the `preinstall` script from both `@edium/halifax` and `@edium/halifax-client`. The
11
- script was a developer-convenience guard that enforced pnpm usage inside the monorepo, but because it shipped in the published package it caused npm (v7+) to prompt consumers with an "approve build scripts" confirmation on every install. End-users no longer need to approve anything to install either package.
171
+ script was a developer-convenience guard that enforced pnpm usage inside the monorepo, but because it shipped in the published package it caused npm (v7+) to prompt consumers with an "approve build scripts" confirmation on every install. End-users no longer need to approve anything to install either package.
12
172
 
13
173
  ## [2.2.1]
14
174
 
package/README.md CHANGED
@@ -157,21 +157,21 @@ app.listen(3000)
157
157
 
158
158
  ## Documentation
159
159
 
160
- | Guide | Contents |
161
- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
162
- | [README_CLIENT.md](./README_CLIENT.md) · [npm](https://www.npmjs.com/package/@edium/halifax-client) | `@edium/halifax-client` — install, transports, query builder, React & Vue TanStack Query examples |
163
- | [README_AUTOCRUD.md](./README_AUTOCRUD.md) | Resource definitions, field flags, ID types, pagination, query-string filtering, error shapes |
164
- | [README_REPO_ADAPTERS.md](./README_REPO_ADAPTERS.md) | Prisma 7 (and 6) setup, `PrismaAdapter`, `DrizzleAdapter`, capabilities, custom repositories |
165
- | [README_HTTP_ADAPTERS.md](./README_HTTP_ADAPTERS.md) | Express, Fastify, HyperExpress & Ultimate Express adapters, and custom HTTP adapters |
166
- | [README_AUTH.md](./README_AUTH.md) | Auth strategies (`ApiKey`, `JWT`, `Passport`), `requiredPermissions`, per-field `readRoles`/`writeRoles` |
167
- | [README_MULTITENANCY.md](./README_MULTITENANCY.md) | Tenant isolation: `tenant` options, auto-detection, scoping guarantees, fail-closed behaviour |
168
- | [README_QUERYBUILDER.md](./README_QUERYBUILDER.md) | Query-builder payload, comparisons, nested filters, portable execution |
169
- | [README_CACHE.md](./README_CACHE.md) | Read-through caching: in-memory & Redis stores, never-expire, cache-bust header |
170
- | [README_HOOKS.md](./README_HOOKS.md) | Lifecycle hooks: `beforeCreate`, `afterCreate`, `beforeReadMany`, `beforeQuery`, and every other hook |
171
- | [README_OPENAPI.md](./README_OPENAPI.md) | OpenAPI 3.1 spec generation, Swagger UI, type introspection, security schemes, programmatic use |
172
- | [README_TYPES.md](./README_TYPES.md) | All exported type aliases, enums (`SqlComparison`, `SqlOperator`, `SqlOrder`), and constants |
173
- | [README_INTERFACES.md](./README_INTERFACES.md) | All exported interfaces — resource, auth, HTTP, repository, cache, Prisma, Drizzle, query AST |
174
- | [README_CLASSES.md](./README_CLASSES.md) | All exported classes — auth strategies, HTTP adapters, ORM adapters, cache stores, error types |
160
+ | Guide | Contents |
161
+ | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
162
+ | [README_CLIENT.md](./README_CLIENT.md) · [npm](https://www.npmjs.com/package/@edium/halifax-client) | `@edium/halifax-client` — install, transports, query builder, React & Vue TanStack Query examples |
163
+ | [README_AUTOCRUD.md](./README_AUTOCRUD.md) | Resource definitions, field flags, ID types, pagination, query-string filtering, error shapes |
164
+ | [README_REPO_ADAPTERS.md](./README_REPO_ADAPTERS.md) | Prisma 7 (and 6) setup, `PrismaAdapter`, `DrizzleAdapter`, capabilities, custom repositories |
165
+ | [README_HTTP_ADAPTERS.md](./README_HTTP_ADAPTERS.md) | Express, Fastify, HyperExpress & Ultimate Express adapters, and custom HTTP adapters |
166
+ | [README_AUTH.md](./README_AUTH.md) | Auth strategies (`ApiKey`, `JWT`, `Passport`), `requiredPermissions`, per-field `readRoles`/`writeRoles` |
167
+ | [README_MULTITENANCY.md](./README_MULTITENANCY.md) | Tenant isolation: `tenant` options, auto-detection, scoping guarantees, fail-closed behaviour |
168
+ | [README_QUERYBUILDER.md](./README_QUERYBUILDER.md) | Query-builder payload, comparisons, nested filters, portable execution |
169
+ | [README_CACHE.md](./README_CACHE.md) | Read-through caching: in-memory & Redis stores, never-expire, cache-bust header |
170
+ | [README_HOOKS.md](./README_HOOKS.md) | Lifecycle hooks: `beforeCreate`, `afterCreate`, `beforeReadMany`, `beforeQuery`, and every other hook |
171
+ | [README_OPENAPI.md](./README_OPENAPI.md) | OpenAPI 3.1 spec generation, Swagger UI, type introspection, security schemes, programmatic use |
172
+ | [README_TYPES.md](./README_TYPES.md) | All exported type aliases, enums (`SqlComparison`, `SqlOperator`, `SqlOrder`), and constants |
173
+ | [README_INTERFACES.md](./README_INTERFACES.md) | All exported interfaces — resource, auth, HTTP, repository, cache, Prisma, Drizzle, query AST |
174
+ | [README_CLASSES.md](./README_CLASSES.md) | All exported classes — auth strategies, HTTP adapters, ORM adapters, cache stores, error types |
175
175
 
176
176
  ## Examples
177
177
 
package/README_AUTH.md CHANGED
@@ -37,7 +37,7 @@ const authStrategy = new ApiKeyAuthStrategy(process.env.API_KEY ?? '')
37
37
  const authStrategy = new ApiKeyAuthStrategy(process.env.API_KEY ?? '', 'x-token')
38
38
  ```
39
39
 
40
- Wrong or missing key → 403.
40
+ Missing header 401 Unauthorized. Wrong key → 403 Forbidden.
41
41
 
42
42
  ### `JwtClaimsAuthStrategy`
43
43
 
@@ -194,7 +194,7 @@ class RoleBasedStrategy implements AuthStrategy {
194
194
 
195
195
  authorize({ auth, action, resource, requiredPermissions }) {
196
196
  if (auth.roles.includes('admin')) return true
197
- return requiredPermissions.every((p) => auth.permissions.includes(p) || auth.roles.includes(p))
197
+ return requiredPermissions.some((p) => auth.permissions.includes(p) || auth.roles.includes(p))
198
198
  }
199
199
  }
200
200
  ```
@@ -100,9 +100,9 @@ Each entry in `fields` accepts four optional boolean flags. All default to `true
100
100
 
101
101
  | Flag | Effect when `false` |
102
102
  | ------------ | --------------------------------------------------------------------- |
103
- | `filterable` | Rejects the field as a query-string filter (`?fieldName=value`) → 400 |
104
- | `sortable` | Rejects the field in `?order=` and query-builder `orderBy` → 400 |
105
- | `selectable` | Rejects the field in `?fields=` and query-builder `fields` → 400 |
103
+ | `filterable` | Rejects the field as a query-string filter (`?fieldName=value`) → 422 |
104
+ | `sortable` | Rejects the field in `?order=` and query-builder `orderBy` → 422 |
105
+ | `selectable` | Rejects the field in `?fields=` and query-builder `fields` → 422 |
106
106
  | `writable` | Silently strips the field from POST / PATCH / PUT request bodies |
107
107
 
108
108
  Example — `role` is fully locked down; `createdAt` can be read and sorted but never written or filtered:
@@ -295,7 +295,7 @@ The `?where` / query-builder `children` filter lets callers nest conditions. To
295
295
  }
296
296
  ```
297
297
 
298
- Requests that exceed the limit receive **400 VALIDATION_ERROR**.
298
+ Requests that exceed the limit receive **422 UNPROCESSABLE_ENTITY**.
299
299
 
300
300
  ## Error Response Shape
301
301
 
@@ -320,6 +320,7 @@ All errors follow the same envelope — an `errors` array where each item has a
320
320
  | 404 | `NOT_FOUND` | `NotFoundError` | Record not found |
321
321
  | 405 | `METHOD_NOT_ALLOWED` | `MethodNotAllowedError` | HTTP method not enabled for this resource |
322
322
  | 406 | `NOT_ACCEPTABLE` | `NotAcceptableError` | `Accept` header excludes `application/json` |
323
+ | 409 | `CONFLICT` | `ConflictError` | Write rejected due to a unique constraint violation |
323
324
  | 415 | `UNSUPPORTED_MEDIA_TYPE` | `UnsupportedMediaTypeError` | Request body is not `application/json` |
324
325
  | 422 | `UNPROCESSABLE_ENTITY` | `UnprocessableEntityError` | Semantic validation failure — unknown field, invalid filter, sort/select restriction, depth exceeded |
325
326
  | 500 | `INTERNAL_ERROR` | `ServerError` | Repository misconfigured or unhandled internal error |
package/README_CACHE.md CHANGED
@@ -149,6 +149,12 @@ class MyCacheStore implements CacheStore {
149
149
  async delete(key: string): Promise<void> {
150
150
  /* … */
151
151
  }
152
+ // Optional — implement for atomic version bumps under concurrent writes.
153
+ // When omitted, Halifax falls back to a non-atomic GET + SET (safe for
154
+ // single-process stores like InMemoryCacheStore, but not for Redis).
155
+ async increment(key: string): Promise<number> {
156
+ /* … return new integer value */
157
+ }
152
158
  }
153
159
  ```
154
160
 
package/README_CLASSES.md CHANGED
@@ -28,13 +28,14 @@ Import: `@edium/halifax`
28
28
  Reads a request header and compares it against a static shared secret.
29
29
 
30
30
  ```ts
31
- new ApiKeyAuthStrategy(expectedApiKey: string, headerName?: string)
31
+ new ApiKeyAuthStrategy(expectedApiKey: string, headerName?: string, roles?: string[])
32
32
  ```
33
33
 
34
- | Parameter | Default | Description |
35
- | ---------------- | ------------- | ----------------------------------- |
36
- | `expectedApiKey` | required | The secret key callers must supply. |
37
- | `headerName` | `'x-api-key'` | Header to read the key from. |
34
+ | Parameter | Default | Description |
35
+ | ---------------- | ------------- | ------------------------------------------------------------------------ |
36
+ | `expectedApiKey` | required | The secret key callers must supply. |
37
+ | `headerName` | `'x-api-key'` | Header to read the key from. |
38
+ | `roles` | `[]` | Role strings attached to `auth.roles` on every successfully authed call. |
38
39
 
39
40
  - Missing header → 401 Unauthorized
40
41
  - Wrong key → 403 Forbidden
@@ -208,7 +209,12 @@ new PrismaAdapter<TRecord, TCreate, TUpdate>(options: PrismaAdapterOptions)
208
209
 
209
210
  See `PrismaAdapterOptions` in [README_INTERFACES.md](./README_INTERFACES.md).
210
211
 
211
- **Static method:** `PrismaAdapter` has no static methods. For auto-generating resources from all Prisma models, use `createPrismaResources` (a standalone function).
212
+ **Static methods:**
213
+
214
+ - `PrismaAdapter.fieldsFromModel(model: ModelSchema): FieldDefinition[]` — derives a Halifax field schema from a Prisma DMMF model. Used internally by the constructor when `options.model` is provided; also callable standalone when you want to inspect or override fields before constructing the adapter.
215
+ - `PrismaAdapter.relationsFromModel(model: ModelSchema): RelationDefinition[]` — derives relation definitions from object-kind fields in the model.
216
+
217
+ For auto-generating resources from all Prisma models at once, use `createPrismaResources` (a standalone function).
212
218
 
213
219
  **Capabilities reported:**
214
220
 
@@ -314,6 +320,7 @@ Base class for all Halifax HTTP errors. Not instantiated directly.
314
320
  | `NotFoundError` | 404 | Record with the given ID does not exist. Throw from a custom `Repository.getOne`. |
315
321
  | `MethodNotAllowedError` | 405 | HTTP method not enabled for this resource. Used internally by Halifax. |
316
322
  | `NotAcceptableError` | 406 | Client `Accept` header excludes `application/json`. Used internally by Halifax. |
323
+ | `ConflictError` | 409 | Write rejected due to a unique constraint violation. Thrown by `PrismaAdapter` and `DrizzleAdapter` automatically; throw it from a custom repository for the same semantics. |
317
324
  | `UnsupportedMediaTypeError` | 415 | Body-carrying request with non-JSON `Content-Type`. Used internally by Halifax. |
318
325
  | `UnprocessableEntityError` | 422 | Unknown fields in body, missing required filter, empty update payload. |
319
326
  | `NotImplementedError` | 501 | Repository does not support this operation (e.g. `upsertOne` not implemented). |
@@ -24,7 +24,7 @@ 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
30
 
@@ -404,11 +404,12 @@ Import: `@edium/halifax`
404
404
 
405
405
  Contract for pluggable cache backends. Implement this to use Redis, Memcached, or any other store.
406
406
 
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. |
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. |
412
+ | `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
413
 
413
414
  ---
414
415
 
@@ -433,11 +434,12 @@ Import: `@edium/halifax`
433
434
 
434
435
  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
436
 
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. |
437
+ | Method | Signature | Description |
438
+ | ------ | -------------------------------------------------- | ----------------------------------------------------------------------------------- |
439
+ | `get` | `(key: string) => Promise<string \| null>` | Read a key. |
440
+ | `set` | `(key, value, options?) => Promise<unknown>` | Write a key. `options.EX` sets TTL in seconds. |
441
+ | `del` | `(key: string) => Promise<unknown>` | Delete a key. |
442
+ | `incr` | `(key: string) => Promise<number>` *(optional)* | Atomically increment a key. Used by `RedisCacheStore.increment` for version bumps when the client exposes this method. |
441
443
 
442
444
  ---
443
445
 
package/README_OPENAPI.md CHANGED
@@ -284,7 +284,7 @@ The query builder endpoint accepts a `QueryOptions` body for full-featured filte
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"],
@@ -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);
@@ -1,5 +1,19 @@
1
+ import { ConflictError } from '../../../errors/ConflictError.js';
1
2
  import { count, eq, getTableColumns, and, inArray, asc, desc } from 'drizzle-orm';
2
3
  import { astToDrizzleWhere, astToDrizzleOrderBy } from './astToDrizzle.js';
4
+ /** Detects unique constraint violations across PostgreSQL (23505), MySQL (1062/ER_DUP_ENTRY), and SQLite. */
5
+ function isDuplicateError(error) {
6
+ if (typeof error !== 'object' || error === null)
7
+ return false;
8
+ const e = error;
9
+ if (e['code'] === '23505')
10
+ return true;
11
+ if (e['errno'] === 1062 || e['code'] === 'ER_DUP_ENTRY')
12
+ return true;
13
+ if (typeof e['message'] === 'string' && e['message'].includes('UNIQUE constraint failed'))
14
+ return true;
15
+ return false;
16
+ }
3
17
  function drizzleTypeToOpenApi(col) {
4
18
  switch (col.dataType) {
5
19
  case 'string':
@@ -54,6 +68,8 @@ export class DrizzleAdapter {
54
68
  table;
55
69
  fields;
56
70
  idField;
71
+ /** Drizzle uses `.returning()` for inserts/updates, so it always returns created records. */
72
+ capabilities = { supportsIncludes: false, supportsCreateManyReturn: true };
57
73
  columns;
58
74
  scope;
59
75
  constructor(db, table, config = {}, scope = null) {
@@ -176,11 +192,18 @@ export class DrizzleAdapter {
176
192
  return { count: total, results: rows };
177
193
  }
178
194
  async createOne(data, _options) {
179
- const rows = (await this.db
180
- .insert(this.table)
181
- .values(this.scope ? { ...data, [this.scope.field]: this.scope.value } : data)
182
- .returning());
183
- return rows[0];
195
+ try {
196
+ const rows = (await this.db
197
+ .insert(this.table)
198
+ .values(this.scope ? { ...data, [this.scope.field]: this.scope.value } : data)
199
+ .returning());
200
+ return rows[0];
201
+ }
202
+ catch (error) {
203
+ if (isDuplicateError(error))
204
+ throw new ConflictError();
205
+ throw error;
206
+ }
184
207
  }
185
208
  async createMany(data, _options) {
186
209
  if (!data.length)
@@ -188,20 +211,40 @@ export class DrizzleAdapter {
188
211
  const stamped = this.scope
189
212
  ? data.map((d) => ({ ...d, [this.scope.field]: this.scope.value }))
190
213
  : data;
191
- const rows = (await this.db.insert(this.table).values(stamped).returning());
192
- return rows;
214
+ try {
215
+ const rows = (await this.db.insert(this.table).values(stamped).returning());
216
+ return rows;
217
+ }
218
+ catch (error) {
219
+ if (isDuplicateError(error))
220
+ throw new ConflictError();
221
+ throw error;
222
+ }
193
223
  }
194
224
  async updateOne(id, data) {
195
225
  const idWhere = eq(this.columns[this.idField], id);
196
226
  const where = this.withScopeWhere(idWhere);
197
- const rows = (await this.db
198
- .update(this.table)
199
- .set(this.stripScope(data))
200
- .where(where)
201
- .returning());
202
- return rows[0] ?? null;
227
+ try {
228
+ const rows = (await this.db
229
+ .update(this.table)
230
+ .set(this.stripScope(data))
231
+ .where(where)
232
+ .returning());
233
+ return rows[0] ?? null;
234
+ }
235
+ catch (error) {
236
+ if (isDuplicateError(error))
237
+ throw new ConflictError();
238
+ throw error;
239
+ }
203
240
  }
204
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.
205
248
  const existing = await this.getOne(id);
206
249
  if (existing) {
207
250
  const updated = await this.updateOne(id, data);
@@ -250,6 +293,6 @@ export class DrizzleAdapter {
250
293
  return { count: total, results: rows };
251
294
  }
252
295
  withScope(scope) {
253
- return new DrizzleAdapter(this.db, this.table, { idField: this.idField }, scope ?? null);
296
+ return new DrizzleAdapter(this.db, this.table, { idField: this.idField }, scope);
254
297
  }
255
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