@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
package/CHANGELOG.md CHANGED
@@ -3,6 +3,208 @@
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.4.0]
7
+
8
+ ### Breaking
9
+
10
+ - **`RouteHandlerContext.resolveRepo` and `GraphQLResourceContext.resolveRepo` now require a
11
+ third `action: CrudAction` argument** — the resolver function signature has changed from
12
+ `(req: HttpRequest, auth: AuthContext) => Promise<Repository>` to
13
+ `(req: HttpRequest, auth: AuthContext, action: CrudAction) => Promise<Repository>`. The
14
+ `action` parameter carries the name of the CRUD operation being performed (e.g. `'readMany'`,
15
+ `'create'`) so that action-aware logic (such as the new admin bypass) can branch correctly
16
+ inside the closure. All built-in handlers and resolvers pass the correct action. Custom
17
+ integrations that construct a `RouteHandlerContext` or `GraphQLResourceContext` directly must
18
+ update their `resolveRepo` implementation to accept and handle the third argument.
19
+
20
+ - **`GraphQLOptions.enabled` must now be explicitly set to `true`** — previously, supplying a
21
+ `graphql: { ... }` object without `enabled: false` activated the endpoint. The new default is
22
+ off: the endpoint is registered only when `graphql: { enabled: true, ... }` is passed. This
23
+ makes GraphQL a deliberate opt-in and avoids importing the optional `graphql` peer dependency
24
+ unless it is actually used. Existing configurations that relied on the implicit default must
25
+ add `enabled: true`.
26
+
27
+ ### Added
28
+
29
+ - **GraphQL endpoint** — Halifax can now expose a full GraphQL API alongside REST. The schema
30
+ is built at startup from the same resource definitions that drive REST: no separate schema
31
+ file, no annotations. For each resource Halifax generates `get<T>`, `list<T>`, and
32
+ `query<T>` query fields plus `create<T>`, `createMany<T>`, `update<T>`, `updateMany<T>`,
33
+ `upsert<T>`, `delete<T>`, and `deleteMany<T>` mutation fields — only those allowed by
34
+ `resource.permissions` are included. The same auth strategy, tenant scoping, field-level
35
+ security (`readRoles`/`writeRoles`), lifecycle hooks, and caching that apply to REST apply
36
+ identically to GraphQL resolvers. Requires the optional `graphql` (≥ 16.0.0) peer dependency.
37
+
38
+ ```ts
39
+ createExpressCrudRouter(resources, {
40
+ graphql: { enabled: true, path: '/graphql', graphiql: true }
41
+ })
42
+ ```
43
+
44
+ - `GET /graphql` serves the GraphiQL browser IDE (disable with `graphiql: false`).
45
+ - `requireAuth: true` gates even introspection behind `authStrategy.authenticate`.
46
+ - Set `graphql: false` on any `ResourceDefinition` to exclude it from the schema while
47
+ keeping it on REST.
48
+ - See [README_GRAPHQL.md](./README_GRAPHQL.md) for full documentation and examples.
49
+
50
+ - **Admin tenant bypass** — callers whose `auth.roles` or `auth.permissions` matches any entry
51
+ in `TenantOptions.bypassRoles` receive an unscoped repository for read operations
52
+ (`readOne`, `readMany`, `readManyWithQueryBuilder`), allowing them to query across all
53
+ tenants. Write operations (`create`, `updateOne`, `updateMany`, `upsertOne`, `deleteOne`,
54
+ `deleteMany`) are never bypassed — the tenant value on writes continues to come from
55
+ `resolveId`, keeping write provenance tied to authentication rather than client input.
56
+ Admins who want to read a single tenant's data use the standard filter syntax
57
+ (`?companyId=42` on REST, `filter: { companyId: 42 }` in GraphQL) rather than any special
58
+ override mechanism.
59
+
60
+ ```ts
61
+ tenant: {
62
+ resolveId: ({ auth }) => auth.claims?.companyId,
63
+ bypassRoles: ['super_admin', 'support:read-all'], // role OR permission slug
64
+ }
65
+ ```
66
+
67
+ Per-resource override via `ResourceDefinition.bypassTenantRoles` takes precedence over the
68
+ API-wide list. Set it to `[]` on a resource to prevent bypass entirely for that model even
69
+ when a global `bypassRoles` is configured.
70
+
71
+ - **`README_GRAPHQL.md`** — new reference document covering GraphQL opt-in setup, the
72
+ auto-generated schema (queries, mutations, types), per-resource opt-out, the GraphiQL IDE,
73
+ authentication, tenant bypass behaviour, and annotated query/mutation examples.
74
+
75
+ ## [2.3.0]
76
+
77
+ ### Security
78
+
79
+ - **`PrismaAdapter.upsertOne` cross-tenant write vulnerability eliminated** — the previous scoped
80
+ upsert path performed `findFirst({ where: { id } })` without a tenant filter, then called
81
+ `delegate.upsert({ where: { id } })` also without a tenant filter. The application-level
82
+ ownership check between the two statements created a TOCTOU window: if another tenant's record
83
+ appeared at that ID between the check and the upsert, Prisma's `upsert` would fire its `update`
84
+ branch and write into that other tenant's row. The scoped path no longer uses `delegate.upsert`.
85
+ It now uses a scoped `findFirst(scopedWhere)` to detect existence, `updateMany(scopedWhere)` for
86
+ the update branch (atomic, same fix applied to `updateOne`), and `create(stampTenant)` for the
87
+ create branch. The `delegate.upsert` call is retained only for the unscoped path.
88
+
89
+ - **`PrismaAdapter.updateOne` and `deleteOne` unsafe fallbacks removed** — when `updateMany`
90
+ (for `updateOne`) or `deleteMany` (for `deleteOne`) were unavailable on the delegate, the code
91
+ fell back to a two-step scoped-check + unscoped-write sequence. Both halves now throw
92
+ `ServerError` immediately rather than proceeding with an unsafe path. Standard Prisma delegates
93
+ always expose both methods, so this only affects non-standard or mock delegates.
94
+
95
+ - **`requiredPermissions` now uses OR semantics consistently** — all three auth strategies
96
+ (`JwtClaimsAuthStrategy`, `PassportJwtStrategy`, `PassportSessionStrategy`) previously used
97
+ `.every()` (ALL permissions required), while the fallback path in `handlerUtils.ts` correctly
98
+ used `.some()` (ANY single permission grants access). The strategies were wrong. All four paths
99
+ now delegate to a shared `checkRequiredPermissions()` utility that applies `.some()` semantics:
100
+ a caller who holds *any one* of the listed permissions is authorized. This matches the documented
101
+ behavior of `readRoles`/`writeRoles` at the field level and is a consistent read of "required
102
+ permissions" as an OR list. **If you relied on the undocumented ALL-must-match behavior,
103
+ tighten your permission model accordingly.**
104
+
105
+ - **TOCTOU race in `PrismaAdapter.updateOne` under tenant scope eliminated** — the previous
106
+ scoped-update path did a `findFirst` ownership check and then a separate `update` call. Between
107
+ the two statements, a concurrent request could transfer the record to a different tenant,
108
+ allowing the update to land on a record the caller no longer owns. The fix uses
109
+ `updateMany({ where: scopedWhere })` as the primary path when the Prisma delegate supports it —
110
+ the tenant field is enforced atomically in a single SQL statement. A safe `findFirst`-then-update
111
+ fallback remains only for delegates that lack `updateMany`.
112
+
113
+ ### Performance
114
+
115
+ - **O(n²) → O(n) field validation** — `validateSelectableFields` and `validateSortableFields`
116
+ previously called `.find()` inside a `.filter()`, giving O(n²) complexity per request. Both now
117
+ pre-build a `Map` from field names to definitions and do O(1) lookups in the filter pass.
118
+
119
+ - **`makeReadableFieldFilter` factory for bulk-response field stripping** — `filterReadableFields`
120
+ previously rebuilt the `fieldMap` `Map` and `userRoles` `Set` for every record in a batch. A
121
+ new exported factory `makeReadableFieldFilter(resource, auth)` builds both structures once and
122
+ returns a reusable `(record) => record` function. The `readMany`, `query`, and `updateMany`
123
+ handlers now call the factory once before `.map()` and pass the compiled function directly,
124
+ eliminating O(n) redundant Map constructions for responses with up to 5000 records.
125
+
126
+ - **`parseGetOneOptions` for lean GET /:id parsing** — `readOne` previously called
127
+ `parseListOptions`, which parses and validates `?fields=`, `?include=`, `?limit=`, `?offset=`,
128
+ `?order=`, and all filter fields — all of which are irrelevant to a single-record GET. A new
129
+ exported `parseGetOneOptions(query, resource)` in `queryString.ts` only parses and validates
130
+ `?fields=` and `?include=`, removing the wasted work and the silent discard of inapplicable
131
+ parameters on that route.
132
+
133
+ ### Fixed
134
+
135
+ - **OpenAPI `distinct` type corrected** — the query-builder endpoint's `distinct` parameter was
136
+ documented as `{ type: 'boolean' }` in the generated spec. The actual type is `string[]` (an
137
+ array of field names to de-duplicate on). The spec now documents it as
138
+ `{ type: 'array', items: { type: 'string' } }` with an accurate description.
139
+
140
+ - **`normalizeEnvelope` and `mergeRelationDefinitions` extracted to `fields.ts`** — both
141
+ utilities existed in identical form in `crudRouter.ts` and `specGenerator.ts`. They are now
142
+ exported from `src/core/fields.ts` alongside `mergeFieldDefinitions`, and both files import
143
+ from there. Behavior is unchanged.
144
+
145
+ - **`checkRequiredPermissions` extracted to `src/auth/strategies/types.ts`** — the permission
146
+ check logic was duplicated (with incompatible `.every()` vs `.some()` semantics) across all
147
+ three strategy files and `handlerUtils.ts`. It is now a single exported function and all four
148
+ call sites use it. See the Security section above for the semantics fix.
149
+
150
+ - **`DrizzleAdapter` reports `supportsIncludes: false`** — the adapter has always silently ignored
151
+ `?include=` requests because Drizzle has no built-in relation eager-loading surface compatible
152
+ with Halifax's interface. The `capabilities` property now explicitly sets `supportsIncludes:
153
+ false`, which causes the router to reject `?include=` with `422 Unprocessable Entity` instead
154
+ of silently returning records with no related data.
155
+
156
+ - **`InMemoryCacheStore` memory growth bounded** — the store previously only evicted expired
157
+ entries lazily on reads, so a write-heavy workload with few reads could accumulate stale entries
158
+ without bound. The `set()` method now runs a full expired-entry sweep every 200 writes
159
+ (configurable via the `sweepEvery` constructor parameter). No external timers or additional
160
+ dependencies.
161
+
162
+ - **`CacheStore.increment` — atomic cache version bumps** — `createCachingRepository` previously
163
+ incremented the version key with a non-atomic `GET` + `SET`, creating a race window under
164
+ concurrent writes on Redis. The `CacheStore` interface gains an optional `increment(key):
165
+ Promise<number> | number` method. `RedisCacheStore` implements it with Redis `INCR` (atomic
166
+ by design). `InMemoryCacheStore` implements it synchronously (race-free in Node.js's
167
+ single-threaded event loop). `createCachingRepository` uses `store.increment` when available
168
+ and falls back to the non-atomic path only for custom stores that do not implement it.
169
+
170
+ - **`DrizzleAdapter.upsertOne` non-atomicity documented** — the method does a `getOne` check
171
+ followed by a `createOne` or `updateOne`, which is non-atomic under concurrent load. A JSDoc
172
+ comment now explains the race condition and recommends implementing a custom repository with a
173
+ database-native `INSERT … ON CONFLICT` clause when true atomicity is required.
174
+
175
+ - **`PrismaAdapter` import order** — all imports were moved above function definitions and
176
+ consolidated into a single, alphabetized block. No behavior change.
177
+
178
+ - **`parseId` dead code removed** — after `validateId(raw)` narrows `raw` to `string`, the
179
+ previous `typeof raw === 'string' ? parseInt(raw, 10) : raw` branch was unreachable. The
180
+ function now calls `parseInt(raw, 10)` directly, and the redundant type guard before the
181
+ UUID/ObjectId check is also removed.
182
+
183
+ - **`LIKE` interior-wildcard limitation documented** — `likeToPrisma` in
184
+ `src/adapters/orm/prisma/astToPrisma.ts` now carries a JSDoc comment explaining that patterns
185
+ with an interior wildcard (e.g. `'foo%bar'`) cannot be expressed via Prisma's string operators
186
+ and fall through to an exact `equals` match, not a wildcard match. Use `CONTAINS`, `STARTS
187
+ WITH`, or `ENDS WITH` comparisons instead of `LIKE` when possible.
188
+
189
+ ### Documentation
190
+
191
+ - **`QueryBuilder` mutability warning** — the class JSDoc now clearly states that all chaining
192
+ methods mutate `this` and return the same instance. A before/after code example demonstrates
193
+ the correct pattern (create a new `QueryBuilder` per branch) and the incorrect pattern (sharing
194
+ one instance and calling `.limit()` twice).
195
+
196
+ - **`andGroup` / `orGroup` API design explained** — both methods require a `field`/`comparison`/
197
+ `value1` parent condition because the Halifax query AST attaches children to a parent filter
198
+ node. The JSDoc now explains this constraint, describes what the resulting predicate looks like
199
+ (`cond AND (children)` vs `cond OR (children)`), and suggests a workaround for expressing a
200
+ pure parenthesized group with no meaningful parent condition.
201
+
202
+ - **`specGenerator` raw-vs-normalized resource note** — a comment in `generateOpenApiSpec`
203
+ explains that the function operates on raw `ResourceDefinition` objects (not the normalized
204
+ forms produced by `crudRouter.normalizeResource`) and therefore re-derives merged fields and
205
+ relations independently. This is intentional — the spec generator is also usable as a
206
+ standalone function outside the router.
207
+
6
208
  ## [2.2.3]
7
209
 
8
210
  ### Added
package/README.md CHANGED
@@ -17,9 +17,10 @@ The package is split into small, replaceable layers — nothing is imported into
17
17
  - 🗄️ **Two ORM adapters, six databases** — `PrismaAdapter` covers PostgreSQL, MySQL, MariaDB, SQL Server, CockroachDB, and SQLite; `DrizzleAdapter` (sub-path `@edium/halifax/drizzle`) covers PostgreSQL, MySQL, SQLite, and LibSQL. Both compile to ORM calls (never raw SQL) so the same query behaves identically across engines.
18
18
  - 🔎 **Dynamic query-builder endpoint** — let the front-end compose rich filtered/sorted/paginated queries "for free" (`AND`/`OR`/nesting, `IN`, `BETWEEN`, `CONTAINS`, …) without hand-writing endpoints. Fully validated — bad fields/operators return structured `4xx` errors, never leaked DB internals.
19
19
  - 📄 **OpenAPI 3.1 generation** — zero-annotation spec generated from your resource definitions at startup. Prisma and Drizzle types are introspected automatically; custom repos annotate individual fields. Swagger UI at `/docs`, raw spec at `/openapi.json`. Disable with `enabled: false` for zero production overhead.
20
- - 🏢 **Multi-tenancy built in** — per-resource tenant scoping with fail-closed guarantees; one tenant can never read or write another's rows.
20
+ - 🏢 **Multi-tenancy built in** — per-resource tenant scoping with fail-closed guarantees; one tenant can never read or write another's rows. Privileged roles (admin, super-admin) can optionally bypass scoping to read across all tenants, with per-resource granularity. See [README_MULTITENANCY.md](./README_MULTITENANCY.md).
21
21
  - ⚡ **Pluggable read-through caching** — in-memory or Redis, per-resource TTLs, never-expire mode, automatic write-invalidation, tenant-safe keys, and a `Cache-Control` bust header.
22
- - 🔐 **Auth & field-level security** — API key, JWT/Bearer, and Passport strategies; per-action permissions; `filterable`/`sortable`/`selectable`/`writable` field flags; and per-field `readRoles`/`writeRoles` for role-based column visibility.
22
+ - 🔐 **Auth & field-level security** — API key, JWT/Bearer, and Passport strategies; per-action permissions (matched against roles OR permission slugs); `filterable`/`sortable`/`selectable`/`writable` field flags; and per-field `readRoles`/`writeRoles` for role-based column visibility.
23
+ - 🔷 **GraphQL endpoint** — opt-in GraphQL API auto-generated from the same resource definitions as REST. Every query, mutation, filter, sort, and field-level permission works identically. Includes a GraphiQL IDE, per-resource opt-out, and full tenant bypass support. See [README_GRAPHQL.md](./README_GRAPHQL.md).
23
24
  - 🪝 **Lifecycle hooks** — inject custom logic before or after any CRUD operation per resource (`beforeCreate`, `afterCreate`, `beforeReadMany`, `beforeQuery`, …). Stamp audit fields, emit events, enforce ownership, or transform results without writing a custom repository. See [README_HOOKS.md](./README_HOOKS.md).
24
25
  - 📦 **Companion browser client** — [`@edium/halifax-client`](https://www.npmjs.com/package/@edium/halifax-client) is a typed, zero-dependency client with a fluent query builder and built-in TanStack Query helpers (queries + mutation auto-invalidation). Bring your own HTTP library (fetch, axios, ky, ofetch, superagent).
25
26
  - 🧪 **Type-safe & battle-tested** — strict TypeScript, ESM, ships full `.d.ts`; hundreds of unit tests plus the full integration suite run against six real databases + Redis in CI.
@@ -30,9 +31,10 @@ The package is split into small, replaceable layers — nothing is imported into
30
31
  | -------------- | -------------------------------------------------------------------------------------------------------------------- |
31
32
  | HTTP server | Express 4/5, Fastify, HyperExpress, Ultimate Express |
32
33
  | ORM / database | Prisma 6 or 7 (Postgres, MySQL, MariaDB, SQL Server, CockroachDB, SQLite); Drizzle (Postgres, MySQL, SQLite, LibSQL) |
33
- | Auth | API key, JWT/Bearer, Passport + JWT; per-field `readRoles`/`writeRoles` |
34
+ | Auth | API key, JWT/Bearer, Passport + JWT; per-field `readRoles`/`writeRoles`; role or permission slug |
34
35
  | Caching | Pluggable read-through cache (in-memory default; bring Redis, etc.) |
35
36
  | API docs | OpenAPI 3.1 spec + Swagger UI (optional, zero overhead when disabled) |
37
+ | GraphQL | Auto-generated schema from resource definitions; opt-in; GraphiQL IDE; requires `graphql` ≥ 16 peer dep |
36
38
 
37
39
  Every HTTP adapter is interchangeable and behaves identically — same routes, status codes,
38
40
  error-body shape, and content negotiation — so you can switch frameworks without touching
@@ -168,6 +170,7 @@ app.listen(3000)
168
170
  | [README_QUERYBUILDER.md](./README_QUERYBUILDER.md) | Query-builder payload, comparisons, nested filters, portable execution |
169
171
  | [README_CACHE.md](./README_CACHE.md) | Read-through caching: in-memory & Redis stores, never-expire, cache-bust header |
170
172
  | [README_HOOKS.md](./README_HOOKS.md) | Lifecycle hooks: `beforeCreate`, `afterCreate`, `beforeReadMany`, `beforeQuery`, and every other hook |
173
+ | [README_GRAPHQL.md](./README_GRAPHQL.md) | GraphQL endpoint: opt-in setup, auto-generated schema, GraphiQL IDE, auth, tenant bypass for admins |
171
174
  | [README_OPENAPI.md](./README_OPENAPI.md) | OpenAPI 3.1 spec generation, Swagger UI, type introspection, security schemes, programmatic use |
172
175
  | [README_TYPES.md](./README_TYPES.md) | All exported type aliases, enums (`SqlComparison`, `SqlOperator`, `SqlOrder`), and constants |
173
176
  | [README_INTERFACES.md](./README_INTERFACES.md) | All exported interfaces — resource, auth, HTTP, repository, cache, Prisma, Drizzle, query AST |
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). |