@edium/halifax 1.0.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 (135) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +148 -0
  4. package/README_AUTH.md +172 -0
  5. package/README_AUTOCRUD.md +253 -0
  6. package/README_CACHE.md +164 -0
  7. package/README_HTTP_ADAPTERS.md +309 -0
  8. package/README_MULTITENANCY.md +162 -0
  9. package/README_QUERYBUILDER.md +219 -0
  10. package/README_REPO_ADAPTERS.md +266 -0
  11. package/dist/adapters/http/ExpressAdapter.d.ts +40 -0
  12. package/dist/adapters/http/ExpressAdapter.d.ts.map +1 -0
  13. package/dist/adapters/http/ExpressAdapter.js +109 -0
  14. package/dist/adapters/http/ExpressAdapter.js.map +1 -0
  15. package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +143 -0
  16. package/dist/adapters/orm/prisma/PrismaAdapter.d.ts.map +1 -0
  17. package/dist/adapters/orm/prisma/PrismaAdapter.js +277 -0
  18. package/dist/adapters/orm/prisma/PrismaAdapter.js.map +1 -0
  19. package/dist/adapters/orm/prisma/createPrismaResources.d.ts +15 -0
  20. package/dist/adapters/orm/prisma/createPrismaResources.d.ts.map +1 -0
  21. package/dist/adapters/orm/prisma/createPrismaResources.js +51 -0
  22. package/dist/adapters/orm/prisma/createPrismaResources.js.map +1 -0
  23. package/dist/adapters/orm/prisma/helpers.d.ts +27 -0
  24. package/dist/adapters/orm/prisma/helpers.d.ts.map +1 -0
  25. package/dist/adapters/orm/prisma/helpers.js +45 -0
  26. package/dist/adapters/orm/prisma/helpers.js.map +1 -0
  27. package/dist/adapters/orm/prisma/index.d.ts +4 -0
  28. package/dist/adapters/orm/prisma/index.d.ts.map +1 -0
  29. package/dist/adapters/orm/prisma/index.js +3 -0
  30. package/dist/adapters/orm/prisma/index.js.map +1 -0
  31. package/dist/adapters/orm/prisma/types.d.ts +49 -0
  32. package/dist/adapters/orm/prisma/types.d.ts.map +1 -0
  33. package/dist/adapters/orm/prisma/types.js +2 -0
  34. package/dist/adapters/orm/prisma/types.js.map +1 -0
  35. package/dist/auth/AuthStrategy.d.ts +198 -0
  36. package/dist/auth/AuthStrategy.d.ts.map +1 -0
  37. package/dist/auth/AuthStrategy.js +227 -0
  38. package/dist/auth/AuthStrategy.js.map +1 -0
  39. package/dist/classes/QueryBuilder.d.ts +33 -0
  40. package/dist/classes/QueryBuilder.d.ts.map +1 -0
  41. package/dist/classes/QueryBuilder.js +262 -0
  42. package/dist/classes/QueryBuilder.js.map +1 -0
  43. package/dist/core/crudRouter.d.ts +36 -0
  44. package/dist/core/crudRouter.d.ts.map +1 -0
  45. package/dist/core/crudRouter.js +391 -0
  46. package/dist/core/crudRouter.js.map +1 -0
  47. package/dist/core/queryString.d.ts +13 -0
  48. package/dist/core/queryString.d.ts.map +1 -0
  49. package/dist/core/queryString.js +89 -0
  50. package/dist/core/queryString.js.map +1 -0
  51. package/dist/core/types.d.ts +293 -0
  52. package/dist/core/types.d.ts.map +1 -0
  53. package/dist/core/types.js +13 -0
  54. package/dist/core/types.js.map +1 -0
  55. package/dist/core/validation.d.ts +75 -0
  56. package/dist/core/validation.d.ts.map +1 -0
  57. package/dist/core/validation.js +206 -0
  58. package/dist/core/validation.js.map +1 -0
  59. package/dist/enums/SqlComparison.d.ts +18 -0
  60. package/dist/enums/SqlComparison.d.ts.map +1 -0
  61. package/dist/enums/SqlComparison.js +19 -0
  62. package/dist/enums/SqlComparison.js.map +1 -0
  63. package/dist/enums/SqlOperator.d.ts +6 -0
  64. package/dist/enums/SqlOperator.d.ts.map +1 -0
  65. package/dist/enums/SqlOperator.js +7 -0
  66. package/dist/enums/SqlOperator.js.map +1 -0
  67. package/dist/enums/SqlOrder.d.ts +6 -0
  68. package/dist/enums/SqlOrder.d.ts.map +1 -0
  69. package/dist/enums/SqlOrder.js +7 -0
  70. package/dist/enums/SqlOrder.js.map +1 -0
  71. package/dist/errors/AuthenticationError.d.ts +10 -0
  72. package/dist/errors/AuthenticationError.d.ts.map +1 -0
  73. package/dist/errors/AuthenticationError.js +13 -0
  74. package/dist/errors/AuthenticationError.js.map +1 -0
  75. package/dist/errors/AuthorizationError.d.ts +10 -0
  76. package/dist/errors/AuthorizationError.d.ts.map +1 -0
  77. package/dist/errors/AuthorizationError.js +13 -0
  78. package/dist/errors/AuthorizationError.js.map +1 -0
  79. package/dist/errors/BadRequestError.d.ts +10 -0
  80. package/dist/errors/BadRequestError.d.ts.map +1 -0
  81. package/dist/errors/BadRequestError.js +13 -0
  82. package/dist/errors/BadRequestError.js.map +1 -0
  83. package/dist/errors/HttpError.d.ts +12 -0
  84. package/dist/errors/HttpError.d.ts.map +1 -0
  85. package/dist/errors/HttpError.js +17 -0
  86. package/dist/errors/HttpError.js.map +1 -0
  87. package/dist/errors/MethodNotAllowedError.d.ts +10 -0
  88. package/dist/errors/MethodNotAllowedError.d.ts.map +1 -0
  89. package/dist/errors/MethodNotAllowedError.js +13 -0
  90. package/dist/errors/MethodNotAllowedError.js.map +1 -0
  91. package/dist/errors/NotAcceptableError.d.ts +10 -0
  92. package/dist/errors/NotAcceptableError.d.ts.map +1 -0
  93. package/dist/errors/NotAcceptableError.js +13 -0
  94. package/dist/errors/NotAcceptableError.js.map +1 -0
  95. package/dist/errors/NotFoundError.d.ts +10 -0
  96. package/dist/errors/NotFoundError.d.ts.map +1 -0
  97. package/dist/errors/NotFoundError.js +13 -0
  98. package/dist/errors/NotFoundError.js.map +1 -0
  99. package/dist/errors/NotImplementedError.d.ts +10 -0
  100. package/dist/errors/NotImplementedError.d.ts.map +1 -0
  101. package/dist/errors/NotImplementedError.js +13 -0
  102. package/dist/errors/NotImplementedError.js.map +1 -0
  103. package/dist/errors/ServerError.d.ts +10 -0
  104. package/dist/errors/ServerError.d.ts.map +1 -0
  105. package/dist/errors/ServerError.js +13 -0
  106. package/dist/errors/ServerError.js.map +1 -0
  107. package/dist/errors/UnprocessableEntityError.d.ts +10 -0
  108. package/dist/errors/UnprocessableEntityError.d.ts.map +1 -0
  109. package/dist/errors/UnprocessableEntityError.js +13 -0
  110. package/dist/errors/UnprocessableEntityError.js.map +1 -0
  111. package/dist/errors/UnsupportedMediaTypeError.d.ts +10 -0
  112. package/dist/errors/UnsupportedMediaTypeError.d.ts.map +1 -0
  113. package/dist/errors/UnsupportedMediaTypeError.js +13 -0
  114. package/dist/errors/UnsupportedMediaTypeError.js.map +1 -0
  115. package/dist/index.d.ts +27 -0
  116. package/dist/index.d.ts.map +1 -0
  117. package/dist/index.js +27 -0
  118. package/dist/index.js.map +1 -0
  119. package/dist/interfaces/IParamQuery.d.ts +8 -0
  120. package/dist/interfaces/IParamQuery.d.ts.map +1 -0
  121. package/dist/interfaces/IParamQuery.js +2 -0
  122. package/dist/interfaces/IParamQuery.js.map +1 -0
  123. package/dist/interfaces/IQueryFilter.d.ts +18 -0
  124. package/dist/interfaces/IQueryFilter.d.ts.map +1 -0
  125. package/dist/interfaces/IQueryFilter.js +2 -0
  126. package/dist/interfaces/IQueryFilter.js.map +1 -0
  127. package/dist/interfaces/IQueryOptions.d.ts +20 -0
  128. package/dist/interfaces/IQueryOptions.d.ts.map +1 -0
  129. package/dist/interfaces/IQueryOptions.js +2 -0
  130. package/dist/interfaces/IQueryOptions.js.map +1 -0
  131. package/dist/interfaces/ISort.d.ts +9 -0
  132. package/dist/interfaces/ISort.d.ts.map +1 -0
  133. package/dist/interfaces/ISort.js +2 -0
  134. package/dist/interfaces/ISort.js.map +1 -0
  135. package/package.json +169 -0
@@ -0,0 +1,164 @@
1
+ # Caching
2
+
3
+ Halifax can serve reads from a **pluggable read-through cache**. It sits at the repository
4
+ layer — _below_ the router's auth checks — so every request is still authenticated and
5
+ authorized; only the database round-trip is skipped. Writes automatically invalidate the
6
+ cache, and clients can force a refresh with a header.
7
+
8
+ - **Read-through**: `getOne`, `getMany`, and the query-builder route are cached.
9
+ - **Auto-invalidation**: any write (`create`/`update`/`upsert`/`delete`, single or bulk) to a
10
+ resource instantly invalidates that resource's cached reads.
11
+ - **Tenant-safe**: cache keys embed the tenant scope, so one tenant can never read another's
12
+ cached rows.
13
+ - **Pluggable store**: in-memory by default; ship a Redis (or any) store by implementing a
14
+ tiny interface.
15
+ - **Never-expire** option and a **cache-bust header** for on-demand refresh.
16
+
17
+ ## Enabling caching
18
+
19
+ Turn it on per-resource, or set an API-wide default. Per-resource config wins.
20
+
21
+ ```ts
22
+ import { createExpressCrudRouter, InMemoryCacheStore } from '@edium/halifax'
23
+
24
+ // Per-resource:
25
+ const countryResource: ResourceDefinition = {
26
+ name: 'Country',
27
+ routePrefix: 'countries',
28
+ fields: [{ name: 'id' }, { name: 'name', filterable: true }],
29
+ cache: { ttlSeconds: 300 }, // cache reads for 5 minutes
30
+ repository: countryRepo
31
+ }
32
+
33
+ // …or an API-wide default applied to every resource:
34
+ createExpressCrudRouter([countryResource, postResource], {
35
+ cache: { ttlSeconds: 60 }
36
+ })
37
+ ```
38
+
39
+ | Setting | Meaning |
40
+ | -------------------------- | ------------------------------------------------------------------------------ |
41
+ | `cache: { ttlSeconds: N }` | Cache reads for `N` seconds (per resource). |
42
+ | `cache: { ttlSeconds: 0 }` | **Never expire** — cache forever (until a write invalidates it). |
43
+ | `cache: false` | Disable caching for this resource even when an API-wide default is set. |
44
+ | _(omitted)_ | Inherit the API-wide `cache.ttlSeconds` default, if any; otherwise no caching. |
45
+
46
+ ### Lookup tables (a common use case)
47
+
48
+ Reference/lookup tables — countries, categories, currencies — are read constantly and change
49
+ rarely, so they are the ideal cache target. Cache them **forever** and let writes invalidate:
50
+
51
+ ```ts
52
+ const categoryResource: ResourceDefinition = {
53
+ name: 'Category',
54
+ routePrefix: 'categories',
55
+ fields: [{ name: 'id' }, { name: 'name', filterable: true, writable: true }],
56
+ cache: { ttlSeconds: 0 }, // never expire — refreshed only when a category is written
57
+ repository: categoryRepo
58
+ }
59
+ ```
60
+
61
+ ## In-memory store (default)
62
+
63
+ When you enable caching without supplying a store, Halifax uses an in-process
64
+ `InMemoryCacheStore`. It's perfect for a single instance and for tests, with no dependencies:
65
+
66
+ ```ts
67
+ import { createExpressCrudRouter, InMemoryCacheStore } from '@edium/halifax'
68
+
69
+ createExpressCrudRouter(resources, {
70
+ cache: { ttlSeconds: 60, store: new InMemoryCacheStore() } // store is optional here
71
+ })
72
+ ```
73
+
74
+ > For multi-instance deployments use a shared store (below) so the cache and its invalidation
75
+ > are consistent across processes.
76
+
77
+ ## Redis store
78
+
79
+ `RedisCacheStore` wraps any Redis client matching a tiny duck-typed interface
80
+ (`get` / `set(key, value, { EX })` / `del`) — the [`redis`](https://www.npmjs.com/package/redis)
81
+ v4 client matches it directly, so Halifax doesn't depend on a specific Redis package.
82
+
83
+ ```ts
84
+ import { createClient } from 'redis'
85
+ import { createExpressCrudRouter, RedisCacheStore } from '@edium/halifax'
86
+
87
+ const redis = createClient({ url: process.env.REDIS_URL })
88
+ await redis.connect()
89
+
90
+ createExpressCrudRouter(resources, {
91
+ cache: {
92
+ ttlSeconds: 300,
93
+ store: new RedisCacheStore(redis, { keyPrefix: 'halifax:' }) // keyPrefix is optional
94
+ }
95
+ })
96
+ ```
97
+
98
+ Values are JSON-serialised; a TTL of `0` stores the key with no Redis expiry (never-expire).
99
+ The optional `keyPrefix` namespaces a shared Redis instance.
100
+
101
+ ## Busting the cache (force refresh)
102
+
103
+ A client can bypass the cache for a single request with a header. By default Halifax honours
104
+ the standard **`Cache-Control: no-cache`** (or `no-store`) request header:
105
+
106
+ ```bash
107
+ curl https://api.example.com/api/countries -H 'Cache-Control: no-cache'
108
+ ```
109
+
110
+ That request reads fresh from the database **and** repopulates the cache, so subsequent
111
+ requests are fast again. Writes still invalidate the cache on their own — busting is only for
112
+ on-demand reads.
113
+
114
+ Use a custom header instead via `cache.bustHeader`; with a custom header, the cache busts
115
+ whenever the header is present with any non-empty value:
116
+
117
+ ```ts
118
+ createExpressCrudRouter(resources, {
119
+ cache: { ttlSeconds: 60, bustHeader: 'X-Cache-Bust' }
120
+ })
121
+ ```
122
+
123
+ ```bash
124
+ curl https://api.example.com/api/countries -H 'X-Cache-Bust: 1'
125
+ ```
126
+
127
+ ## How invalidation works
128
+
129
+ Each resource (per tenant) has a version counter in the store. Cached read keys embed the
130
+ current version; a write bumps the version, so all previously-cached reads for that
131
+ resource/tenant are instantly unreachable and fall out by TTL. There is no key scanning, which
132
+ keeps invalidation cheap on Redis.
133
+
134
+ ## Custom store
135
+
136
+ Implement the `CacheStore` interface to back the cache with anything (Memcached, a CDN KV,
137
+ etc.):
138
+
139
+ ```ts
140
+ import type { CacheStore } from '@edium/halifax'
141
+
142
+ class MyCacheStore implements CacheStore {
143
+ async get(key: string): Promise<unknown> {
144
+ /* … */
145
+ }
146
+ async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
147
+ /* TTL of 0/undefined = no expiry */
148
+ }
149
+ async delete(key: string): Promise<void> {
150
+ /* … */
151
+ }
152
+ }
153
+ ```
154
+
155
+ You can also wrap a repository directly with `createCachingRepository(repo, { store, ttlSeconds, namespace })`
156
+ if you need caching outside the HTTP layer.
157
+
158
+ ## Testing
159
+
160
+ - **Unit** (`pnpm test:unit`) — `InMemoryCacheStore`, the caching decorator, and the
161
+ `Cache-Control` header wiring are covered with no external services.
162
+ - **Integration** (`pnpm test:integration`) — a Redis-backed lookup-table scenario runs
163
+ against a real Redis container (set `REDIS_URL`), proving cache hits, never-expire, key
164
+ presence, header busting, and write invalidation. CI runs it on a `redis:7` service.
@@ -0,0 +1,309 @@
1
+ # HTTP Adapters
2
+
3
+ Halifax's HTTP layer is swappable. Every transport implements the same interface:
4
+
5
+ ```ts
6
+ interface HttpServer {
7
+ registerRoute(method: string, path: string, handler: HttpHandler): void
8
+ start(port: number, host?: string): Promise<void> | void
9
+ }
10
+ ```
11
+
12
+ Halifax ships **four** first-party adapters. They are interchangeable: every adapter
13
+ produces the same routes, status codes, error-body shape (`{ errors: [{ code, message }] }`),
14
+ `Allow`/`X-Correlation-ID` headers, content negotiation (406/415), and method-not-allowed
15
+ (405) behaviour. Switching frameworks does not require any change to your resource
16
+ definitions, auth strategy, or query logic.
17
+
18
+ | Framework | Import path | Factory | Mount style |
19
+ | ---------------- | --------------------------------- | --------------------------------- | ---------------------------------- |
20
+ | Express 4 / 5 | `@edium/halifax/express` | `createExpressCrudRouter` | `app.use('/api', router)` |
21
+ | Fastify | `@edium/halifax/fastify` | `createFastifyCrudPlugin` | `app.register(plugin, { prefix })` |
22
+ | HyperExpress | `@edium/halifax/hyper-express` | `createHyperExpressCrudRouter` | `app.use('/api', router)` |
23
+ | Ultimate Express | `@edium/halifax/ultimate-express` | `createUltimateExpressCrudRouter` | `app.use('/api', router)` |
24
+
25
+ Each adapter is published as its own subpath entry point, so you only ever load the
26
+ framework you actually use — the others are optional peer dependencies and are never
27
+ imported unless you import their subpath.
28
+
29
+ > **A note on routing parity.** Express, HyperExpress, and Ultimate Express all use the
30
+ > same `:id` named-parameter routing and `app.all` / `app.any` catch-all, so behaviour is
31
+ > identical. Fastify uses a radix-tree router (static segments beat parameters) rather than
32
+ > registration order; the adapter compensates so all documented CRUD, 404, 400, 405, 406,
33
+ > and 415 behaviour matches the others exactly. The only observable difference is for
34
+ > contrived paths that collide a static segment with `:id` (e.g. `GET /posts/query`),
35
+ > which is not a real-world CRUD scenario.
36
+
37
+ ---
38
+
39
+ ## Express Adapter
40
+
41
+ Works seamlessly with both **Express 4 and Express 5** — the same code runs unchanged on
42
+ either major.
43
+
44
+ ### Install
45
+
46
+ ```bash
47
+ # Express 5
48
+ pnpm add @edium/halifax express
49
+ pnpm add -D @types/express
50
+
51
+ # …or Express 4 — equally supported
52
+ pnpm add @edium/halifax express@4
53
+ pnpm add -D @types/express@4
54
+ ```
55
+
56
+ ### `createExpressCrudRouter`
57
+
58
+ Returns a standard Express `Router` that you mount wherever you like.
59
+
60
+ ```ts
61
+ import express from 'express'
62
+ import { createExpressCrudRouter } from '@edium/halifax/express'
63
+ import { authStrategy } from './auth.js'
64
+ import { postResource } from './resources/post.js'
65
+
66
+ export function createApp() {
67
+ const app = express()
68
+ app.use(express.json())
69
+ app.use('/api/v1', createExpressCrudRouter([postResource], { authStrategy }))
70
+ return app
71
+ }
72
+ ```
73
+
74
+ #### Options
75
+
76
+ All four factories accept the same options object:
77
+
78
+ | Option | Type | Description |
79
+ | ------------------ | -------------------------------------- | ----------------------------------------------------------- |
80
+ | `authStrategy` | `AuthStrategy` | Auth strategy applied to every route (default: `AllowAll`) |
81
+ | `tenant` | `TenantOptions` | Multi-tenant isolation config (see README_MULTITENANCY.md) |
82
+ | `queryBuilderPath` | `string` | Path segment for the query-builder route (default: `query`) |
83
+ | `cache` | `{ store?, ttlSeconds?, bustHeader? }` | Read-through caching (see README_CACHE.md) |
84
+
85
+ ### `ExpressHttpServer` (lower-level)
86
+
87
+ ```ts
88
+ import { ExpressHttpServer } from '@edium/halifax/express'
89
+ import { registerCrudApi } from '@edium/halifax'
90
+
91
+ const server = new ExpressHttpServer(app)
92
+ registerCrudApi(server, [postResource], { authStrategy })
93
+ server.start(3000)
94
+ ```
95
+
96
+ ---
97
+
98
+ ## Fastify Adapter
99
+
100
+ Fastify's mounting mechanism is plugins-with-prefixes rather than mountable routers, so
101
+ the idiomatic equivalent of `createExpressCrudRouter` is a **plugin**.
102
+
103
+ ### Install
104
+
105
+ ```bash
106
+ pnpm add @edium/halifax fastify
107
+ ```
108
+
109
+ ### `createFastifyCrudPlugin`
110
+
111
+ ```ts
112
+ import Fastify from 'fastify'
113
+ import { createFastifyCrudPlugin } from '@edium/halifax/fastify'
114
+ import { authStrategy } from './auth.js'
115
+ import { postResource } from './resources/post.js'
116
+
117
+ const app = Fastify()
118
+ await app.register(createFastifyCrudPlugin([postResource], { authStrategy }), {
119
+ prefix: '/api/v1'
120
+ })
121
+ await app.listen({ port: 3000 })
122
+ ```
123
+
124
+ Fastify parses JSON request bodies automatically — no body-parser registration is needed.
125
+ The adapter installs a catch-all content-type parser inside its plugin scope so non-JSON
126
+ payloads produce Halifax's structured `415` rather than Fastify's default error page.
127
+
128
+ ### `FastifyHttpServer` (lower-level)
129
+
130
+ ```ts
131
+ import Fastify from 'fastify'
132
+ import { FastifyHttpServer } from '@edium/halifax/fastify'
133
+ import { registerCrudApi } from '@edium/halifax'
134
+
135
+ const app = Fastify()
136
+ registerCrudApi(new FastifyHttpServer(app), [postResource], { authStrategy })
137
+ await app.listen({ port: 3000 })
138
+ ```
139
+
140
+ ---
141
+
142
+ ## HyperExpress Adapter
143
+
144
+ [HyperExpress](https://github.com/kartikk221/hyper-express) is a high-performance,
145
+ Express-flavoured framework built on uWebSockets.js.
146
+
147
+ ### Install
148
+
149
+ ```bash
150
+ pnpm add @edium/halifax hyper-express
151
+ ```
152
+
153
+ ### `createHyperExpressCrudRouter`
154
+
155
+ ```ts
156
+ import HyperExpress from 'hyper-express'
157
+ import { createHyperExpressCrudRouter } from '@edium/halifax/hyper-express'
158
+ import { authStrategy } from './auth.js'
159
+ import { postResource } from './resources/post.js'
160
+
161
+ const server = new HyperExpress.Server()
162
+ server.use('/api/v1', createHyperExpressCrudRouter([postResource], { authStrategy }))
163
+ await server.listen(3000)
164
+ ```
165
+
166
+ No body-parsing middleware is required: the adapter downloads and parses the JSON body for
167
+ you (and leaves non-JSON bodies unparsed so the router can return the structured `415`).
168
+
169
+ ### `HyperExpressHttpServer` (lower-level)
170
+
171
+ ```ts
172
+ import HyperExpress from 'hyper-express'
173
+ import { HyperExpressHttpServer } from '@edium/halifax/hyper-express'
174
+ import { registerCrudApi } from '@edium/halifax'
175
+
176
+ const server = new HyperExpress.Server()
177
+ registerCrudApi(new HyperExpressHttpServer(server), [postResource], { authStrategy })
178
+ await server.listen(3000)
179
+ ```
180
+
181
+ ---
182
+
183
+ ## Ultimate Express Adapter
184
+
185
+ [Ultimate Express](https://github.com/dimdenGD/ultimate-express) is a drop-in
186
+ reimplementation of the Express API on top of uWebSockets.js. Usage is identical to the
187
+ Express adapter.
188
+
189
+ ### Install
190
+
191
+ ```bash
192
+ pnpm add @edium/halifax ultimate-express
193
+ ```
194
+
195
+ ### `createUltimateExpressCrudRouter`
196
+
197
+ ```ts
198
+ import express from 'ultimate-express'
199
+ import { createUltimateExpressCrudRouter } from '@edium/halifax/ultimate-express'
200
+ import { authStrategy } from './auth.js'
201
+ import { postResource } from './resources/post.js'
202
+
203
+ const app = express()
204
+ app.use(express.json())
205
+ app.use('/api/v1', createUltimateExpressCrudRouter([postResource], { authStrategy }))
206
+ app.listen(3000)
207
+ ```
208
+
209
+ ### `UltimateExpressHttpServer` (lower-level)
210
+
211
+ ```ts
212
+ import express from 'ultimate-express'
213
+ import { UltimateExpressHttpServer } from '@edium/halifax/ultimate-express'
214
+ import { registerCrudApi } from '@edium/halifax'
215
+
216
+ const app = express()
217
+ registerCrudApi(new UltimateExpressHttpServer(app), [postResource], { authStrategy })
218
+ app.listen(3000)
219
+ ```
220
+
221
+ > **Note** — `ultimate-express` and `hyper-express` depend on `uWebSockets.js`, which is
222
+ > distributed only as a git repository (not on the npm registry). If your package manager
223
+ > blocks "exotic" git sub-dependencies (pnpm does by default), allow them — this repo's
224
+ > `.npmrc` sets `block-exotic-subdeps=false` for exactly this reason.
225
+
226
+ ---
227
+
228
+ ## With Passport
229
+
230
+ If you are using `PassportJwtStrategy` with Express or Ultimate Express, initialize
231
+ Passport before mounting the router:
232
+
233
+ ```ts
234
+ import passport from 'passport'
235
+
236
+ app.use(passport.initialize())
237
+ app.use('/api/v1', createExpressCrudRouter([postResource], { authStrategy }))
238
+ ```
239
+
240
+ ---
241
+
242
+ ## Implementing a Custom HTTP Adapter
243
+
244
+ To support any other framework, implement `HttpServer`. The contract is small: map the
245
+ framework's request/response onto Halifax's `HttpRequest` / `HttpResponse`, route `'*'` to
246
+ the framework's "any method" handler (for 405 fallbacks), and make `start()` listen.
247
+
248
+ ```ts
249
+ import type { HttpServer, HttpRouteHandler, HttpMethod } from '@edium/halifax'
250
+ import { registerCrudApi } from '@edium/halifax'
251
+
252
+ class MyAdapter implements HttpServer {
253
+ constructor(private app: MyFramework) {}
254
+
255
+ registerRoute(method: HttpMethod, path: string, handler: HttpRouteHandler) {
256
+ const run = (req, res) =>
257
+ handler(
258
+ {
259
+ method: req.method,
260
+ params: req.params,
261
+ query: req.query,
262
+ body: req.body,
263
+ headers: req.headers,
264
+ raw: req
265
+ },
266
+ {
267
+ raw: res,
268
+ status(code) {
269
+ res.statusCode = code
270
+ return this
271
+ },
272
+ json(payload) {
273
+ res.json(payload)
274
+ },
275
+ send(payload) {
276
+ res.send(payload)
277
+ },
278
+ setHeader(name, value) {
279
+ res.setHeader(name, value)
280
+ }
281
+ }
282
+ )
283
+ if (method === '*') this.app.any(path, run)
284
+ else this.app[method.toLowerCase()](path, run)
285
+ }
286
+
287
+ async start(port: number, host?: string) {
288
+ await this.app.listen(port, host)
289
+ }
290
+ }
291
+
292
+ registerCrudApi(new MyAdapter(app), [postResource], { authStrategy })
293
+ ```
294
+
295
+ The four first-party adapters in `src/adapters/http/` are the best reference
296
+ implementations — each is a small, self-contained file.
297
+
298
+ ---
299
+
300
+ ## Testing
301
+
302
+ Every adapter is verified against a single shared **Express-parity contract**
303
+ (`tests/helpers/adapterContract.ts`): the identical set of HTTP assertions is run against
304
+ all frameworks so "behaves exactly like Express" is enforced by construction.
305
+
306
+ - **Unit** (`pnpm test:unit`) — runs the contract against each adapter with an in-memory
307
+ repository; no database required.
308
+ - **Integration** (`pnpm test:integration`) — runs a Prisma + PostgreSQL HTTP contract
309
+ against each adapter. Requires `DATABASE_URL` in `.env.test`; skipped when unset.
@@ -0,0 +1,162 @@
1
+ # Multi-Tenancy
2
+
3
+ Halifax can confine every generated endpoint to a single tenant — a company, organization,
4
+ workspace, or any row-ownership boundary — so that one customer can never read or write
5
+ another customer's data through the API. Tenant isolation is **optional**, **opt-in**, and
6
+ enforced at the data-access layer where it cannot be bypassed by crafted query strings or
7
+ request bodies.
8
+
9
+ ## How it works
10
+
11
+ Two pieces combine to produce a per-request constraint:
12
+
13
+ 1. **The tenant field** — a column on the model that stores the tenant key (e.g. `companyId`).
14
+ Declared per-resource via `ResourceDefinition.tenant`, or auto-detected (see below).
15
+ 2. **The tenant value** — the key the _current caller_ is bound to (e.g. their `company.id`).
16
+ Produced per-request by `tenant.resolveId(ctx)`, which reads it from the authenticated
17
+ session/token — **never** from client-supplied input.
18
+
19
+ For each request, Halifax resolves the value and asks the repository for a **scoped clone**
20
+ (`repository.withScope({ field, value })`). That clone applies the constraint to every
21
+ operation:
22
+
23
+ | Operation | What the scope does |
24
+ | -------------------------------------- | --------------------------------------------------------------------------------- |
25
+ | `GET /resource` (list & query-builder) | AND-s `field = value` into the `WHERE` (and the count) |
26
+ | `GET /resource/:id` | Reads via `findFirst` with the tenant filter → foreign rows return `404` |
27
+ | `POST /resource` | **Stamps** `field = value` onto the body, overriding any client-supplied value |
28
+ | `PATCH /resource/:id` | Verifies ownership first; **strips** the tenant field so rows can't change owners |
29
+ | `PATCH /resource` (updateMany) | AND-s the tenant filter into the `WHERE`; strips the tenant field from the `SET` |
30
+ | `PUT /resource/:id` (upsert) | Refuses to overwrite a row owned by another tenant (`404`); stamps on create |
31
+ | `DELETE /resource/:id` | Deletes via a scoped `deleteMany` → foreign rows report "not found" |
32
+ | `DELETE /resource` (deleteMany) | AND-s the tenant filter into the `WHERE` |
33
+
34
+ ## Quick start
35
+
36
+ ```ts
37
+ import express from 'express'
38
+ import { PrismaClient } from '@prisma/client'
39
+ import { PrismaPg } from '@prisma/adapter-pg'
40
+ import {
41
+ createPrismaResources,
42
+ createExpressCrudRouter,
43
+ PassportSessionStrategy
44
+ } from '@edium/halifax'
45
+ import { Prisma } from '@prisma/client'
46
+
47
+ const prisma = new PrismaClient({ adapter: new PrismaPg(process.env.DATABASE_URL!) })
48
+
49
+ // Generate resources for every model; mark any model that has a `companyId` column
50
+ // as tenant-scoped on it.
51
+ const resources = createPrismaResources(prisma, Prisma.dmmf.datamodel.models, {
52
+ tenantField: 'companyId'
53
+ })
54
+
55
+ const app = express()
56
+ app.use(express.json())
57
+ // ...session + passport middleware run before Halifax...
58
+
59
+ app.use(
60
+ '/api/v3',
61
+ createExpressCrudRouter(resources, {
62
+ authStrategy: new PassportSessionStrategy(),
63
+ tenant: {
64
+ // Derive the tenant key from the authenticated session — never from the request body.
65
+ resolveId: ({ req }) => (req.raw as any).session?.company?.id ?? null
66
+ }
67
+ })
68
+ )
69
+ ```
70
+
71
+ That's the whole integration: one `tenantField` to auto-detect scoped models, and one
72
+ `resolveId` to supply the caller's tenant. Models without a `companyId` column are left
73
+ global (unscoped) automatically.
74
+
75
+ ## Configuration
76
+
77
+ ### API-level: `tenant` on `CrudApiOptions`
78
+
79
+ ```ts
80
+ interface TenantOptions {
81
+ /** Resolve the caller's tenant key from the authenticated request. */
82
+ resolveId: (ctx: { auth; req; resource }) => unknown | Promise<unknown>
83
+ /** Default column to auto-detect scoping on. Defaults to 'tenantId'. */
84
+ field?: string
85
+ /** Fail-closed when no tenant resolves. Defaults to true. */
86
+ strict?: boolean
87
+ }
88
+ ```
89
+
90
+ > `resolveId` runs **after** authentication, so `ctx.auth` is the resolved `AuthContext`
91
+ > and `ctx.req.raw` is the underlying framework request (e.g. the Express `Request`, with
92
+ > `session` / `user` populated). Pull the tenant from there.
93
+
94
+ ### Resource-level: `tenant` on `ResourceDefinition`
95
+
96
+ | Value | Meaning |
97
+ | ---------------- | ------------------------------------------------------------------------------------------- |
98
+ | `{ field: 'x' }` | Scope this resource on column `x`. |
99
+ | `false` | Opt this resource **out** of an otherwise tenant-scoped API. |
100
+ | _omitted_ | Auto-scope on the API's default `field` **if** the model has that column; otherwise global. |
101
+
102
+ When generating with `createPrismaResources`, set it per model:
103
+
104
+ ```ts
105
+ createPrismaResources(prisma, models, {
106
+ tenantField: 'companyId',
107
+ models: {
108
+ // Override the column for one model:
109
+ Invoice: { tenant: { field: 'orgId' } },
110
+ // Expose a global reference table to all tenants:
111
+ Country: { tenant: false }
112
+ }
113
+ })
114
+ ```
115
+
116
+ ## Security model
117
+
118
+ Tenant scoping is designed to **fail closed**. The guarantees:
119
+
120
+ - **Enforced at the data layer.** The constraint is applied inside the repository on every
121
+ read, write, and bulk operation — not as middleware a route could skip.
122
+ - **Server-derived value only.** The tenant value comes from `resolveId` (your auth/session),
123
+ never from the URL, query string, or body. A client-supplied `?companyId=` or a `companyId`
124
+ in the body is **overridden** (writes) or **AND-ed against** (reads), so it cannot widen access.
125
+ - **No `OR` escape in the query builder.** Caller-supplied filters are nested as a
126
+ parenthesised group AND-ed beneath the tenant predicate — `companyId = $1 AND ( ...your filters... )`
127
+ — so an `OR` in the advanced query can never break out of the tenant boundary.
128
+ - **Rows can't change owners.** The tenant field is stripped from update/upsert payloads.
129
+ - **Cross-tenant rows are invisible.** Reads/updates/deletes of another tenant's row behave
130
+ as "not found" (`404`/no-op), never leaking existence.
131
+ - **Misconfiguration is fatal, not silent.** If a resource is tenant-scoped but its repository
132
+ doesn't implement `withScope`, `createExpressCrudRouter` throws at startup rather than
133
+ serving the resource unscoped.
134
+ - **Strict by default.** If `resolveId` returns `null`/`undefined` for a scoped resource, the
135
+ request is rejected with `403`. Set `strict: false` only to deliberately allow cross-tenant
136
+ ("god mode") access for callers with no tenant.
137
+
138
+ ### Defense in depth
139
+
140
+ API-level scoping does not replace database-level controls. For a hard guarantee even against
141
+ bugs elsewhere in your stack, pair Halifax scoping with PostgreSQL **Row-Level Security**
142
+ policies keyed on the same tenant column.
143
+
144
+ ### What is _not_ scoped
145
+
146
+ Relations eager-loaded via `?include=` are returned as Prisma resolves them; if a related
147
+ model also needs isolation, expose it as its own scoped resource rather than relying on the
148
+ include.
149
+
150
+ ## Custom repositories
151
+
152
+ Any repository can support scoping by implementing the optional `withScope` method, which must
153
+ return a **new** instance bound to the scope (never mutate the original):
154
+
155
+ ```ts
156
+ withScope(scope: TenantScope): Repository<...> {
157
+ return new MyRepository({ ...this.options, scope })
158
+ }
159
+ ```
160
+
161
+ `PrismaAdapter` implements this for you. A repository that omits `withScope` simply cannot be
162
+ used for a tenant-scoped resource — the router refuses to register it, by design.