@edium/halifax 2.2.1 → 2.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +15 -15
- package/dist/adapters/orm/drizzle/DrizzleAdapter.js +48 -13
- package/dist/adapters/orm/prisma/PrismaAdapter.js +47 -9
- package/dist/core/handlerUtils.js +1 -0
- package/dist/errors/ConflictError.d.ts +5 -0
- package/dist/errors/ConflictError.js +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/openapi/specGenerator.js +5 -0
- package/package.json +2 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,53 @@
|
|
|
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.2.3]
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`ConflictError`** — new `HttpError` subclass with HTTP status `409`. Exported from
|
|
11
|
+
the main package entry-point so application code can `throw new ConflictError()` in
|
|
12
|
+
hooks or custom repositories and have it serialised correctly.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **409 Conflict on duplicate unique-key violations** — `PrismaAdapter` and `DrizzleAdapter`
|
|
17
|
+
now catch unique-constraint errors from the underlying ORM and re-throw them as
|
|
18
|
+
`ConflictError` (HTTP 409) instead of letting the raw ORM error propagate as an
|
|
19
|
+
unhandled 500.
|
|
20
|
+
- **Prisma**: catches `P2002` (unique constraint failed) on `createOne`, `createMany`,
|
|
21
|
+
`updateOne`, and both branches of `upsertOne`.
|
|
22
|
+
- **Drizzle**: catches PostgreSQL `23505`, MySQL `1062` / `ER_DUP_ENTRY`, and SQLite
|
|
23
|
+
`UNIQUE constraint failed` on `createOne`, `createMany`, and `updateOne`.
|
|
24
|
+
- **`statusCodeMap` now includes `409 → 'CONFLICT'`** — previously, any `HttpError`
|
|
25
|
+
thrown with status `409` would have been serialised with `code: "INTERNAL_ERROR"`.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **OpenAPI spec** — write operations (`POST /{resource}`, `PATCH /{resource}`,
|
|
30
|
+
`PATCH /{resource}/{id}`, `PUT /{resource}/{id}`) now include a `409 Conflict` response
|
|
31
|
+
definition documenting that unique-constraint violations return this status.
|
|
32
|
+
|
|
33
|
+
## [2.2.2]
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- Removed the `preinstall` script from both `@edium/halifax` and `@edium/halifax-client`. The
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
## [2.2.1]
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- Published `README.md` files for `@edium/halifax-client` and `@edium/halifax-types` — both
|
|
45
|
+
packages now ship documentation with the npm tarball.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- `@edium/halifax-types` was re-versioned from `0.1.0` to `2.2.1` to align with the rest of
|
|
50
|
+
the suite. Consumers importing from this package directly should update their version
|
|
51
|
+
constraint accordingly.
|
|
52
|
+
|
|
6
53
|
## [2.2.0]
|
|
7
54
|
|
|
8
55
|
### Added
|
package/README.md
CHANGED
|
@@ -157,21 +157,21 @@ app.listen(3000)
|
|
|
157
157
|
|
|
158
158
|
## Documentation
|
|
159
159
|
|
|
160
|
-
| Guide
|
|
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)
|
|
164
|
-
| [README_REPO_ADAPTERS.md](./README_REPO_ADAPTERS.md)
|
|
165
|
-
| [README_HTTP_ADAPTERS.md](./README_HTTP_ADAPTERS.md)
|
|
166
|
-
| [README_AUTH.md](./README_AUTH.md)
|
|
167
|
-
| [README_MULTITENANCY.md](./README_MULTITENANCY.md)
|
|
168
|
-
| [README_QUERYBUILDER.md](./README_QUERYBUILDER.md)
|
|
169
|
-
| [README_CACHE.md](./README_CACHE.md)
|
|
170
|
-
| [README_HOOKS.md](./README_HOOKS.md)
|
|
171
|
-
| [README_OPENAPI.md](./README_OPENAPI.md)
|
|
172
|
-
| [README_TYPES.md](./README_TYPES.md)
|
|
173
|
-
| [README_INTERFACES.md](./README_INTERFACES.md)
|
|
174
|
-
| [README_CLASSES.md](./README_CLASSES.md)
|
|
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
|
|
|
@@ -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':
|
|
@@ -176,11 +190,18 @@ export class DrizzleAdapter {
|
|
|
176
190
|
return { count: total, results: rows };
|
|
177
191
|
}
|
|
178
192
|
async createOne(data, _options) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
193
|
+
try {
|
|
194
|
+
const rows = (await this.db
|
|
195
|
+
.insert(this.table)
|
|
196
|
+
.values(this.scope ? { ...data, [this.scope.field]: this.scope.value } : data)
|
|
197
|
+
.returning());
|
|
198
|
+
return rows[0];
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
if (isDuplicateError(error))
|
|
202
|
+
throw new ConflictError();
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
184
205
|
}
|
|
185
206
|
async createMany(data, _options) {
|
|
186
207
|
if (!data.length)
|
|
@@ -188,18 +209,32 @@ export class DrizzleAdapter {
|
|
|
188
209
|
const stamped = this.scope
|
|
189
210
|
? data.map((d) => ({ ...d, [this.scope.field]: this.scope.value }))
|
|
190
211
|
: data;
|
|
191
|
-
|
|
192
|
-
|
|
212
|
+
try {
|
|
213
|
+
const rows = (await this.db.insert(this.table).values(stamped).returning());
|
|
214
|
+
return rows;
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
if (isDuplicateError(error))
|
|
218
|
+
throw new ConflictError();
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
193
221
|
}
|
|
194
222
|
async updateOne(id, data) {
|
|
195
223
|
const idWhere = eq(this.columns[this.idField], id);
|
|
196
224
|
const where = this.withScopeWhere(idWhere);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
225
|
+
try {
|
|
226
|
+
const rows = (await this.db
|
|
227
|
+
.update(this.table)
|
|
228
|
+
.set(this.stripScope(data))
|
|
229
|
+
.where(where)
|
|
230
|
+
.returning());
|
|
231
|
+
return rows[0] ?? null;
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
if (isDuplicateError(error))
|
|
235
|
+
throw new ConflictError();
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
203
238
|
}
|
|
204
239
|
async upsertOne(id, data) {
|
|
205
240
|
const existing = await this.getOne(id);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ConflictError } from '../../../errors/ConflictError.js';
|
|
1
2
|
import { NotImplementedError } from '../../../errors/NotImplementedError.js';
|
|
2
3
|
import { NotFoundError } from '../../../errors/NotFoundError.js';
|
|
3
4
|
import { ServerError } from '../../../errors/ServerError.js';
|
|
@@ -9,6 +10,13 @@ function isNotFoundError(error) {
|
|
|
9
10
|
'code' in error &&
|
|
10
11
|
error.code === 'P2025');
|
|
11
12
|
}
|
|
13
|
+
/** Returns true for Prisma's P2002 unique constraint violation. */
|
|
14
|
+
function isDuplicateError(error) {
|
|
15
|
+
return (typeof error === 'object' &&
|
|
16
|
+
error !== null &&
|
|
17
|
+
'code' in error &&
|
|
18
|
+
error.code === 'P2002');
|
|
19
|
+
}
|
|
12
20
|
import { toSelect, toInclude, toOrderBy } from './helpers.js';
|
|
13
21
|
function prismaTypeToOpenApi(prismaType) {
|
|
14
22
|
switch (prismaType) {
|
|
@@ -247,7 +255,14 @@ export class PrismaAdapter {
|
|
|
247
255
|
* @throws ServerError if the Prisma delegate does not support the create method.
|
|
248
256
|
*/
|
|
249
257
|
async createOne(data) {
|
|
250
|
-
|
|
258
|
+
try {
|
|
259
|
+
return (await this.delegate.create({ data: this.stampTenant(data) }));
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
if (isDuplicateError(error))
|
|
263
|
+
throw new ConflictError();
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
251
266
|
}
|
|
252
267
|
/**
|
|
253
268
|
* Creates multiple records in the database using the provided array of data objects.
|
|
@@ -260,7 +275,14 @@ export class PrismaAdapter {
|
|
|
260
275
|
if (!this.delegate.createMany || this.returnCreated) {
|
|
261
276
|
return await Promise.all(data.map((item) => this.createOne(item)));
|
|
262
277
|
}
|
|
263
|
-
|
|
278
|
+
try {
|
|
279
|
+
await this.delegate.createMany({ data: data.map((item) => this.stampTenant(item)) });
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
if (isDuplicateError(error))
|
|
283
|
+
throw new ConflictError();
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
264
286
|
return [];
|
|
265
287
|
}
|
|
266
288
|
/**
|
|
@@ -291,6 +313,8 @@ export class PrismaAdapter {
|
|
|
291
313
|
catch (error) {
|
|
292
314
|
if (isNotFoundError(error))
|
|
293
315
|
return null;
|
|
316
|
+
if (isDuplicateError(error))
|
|
317
|
+
throw new ConflictError();
|
|
294
318
|
throw error;
|
|
295
319
|
}
|
|
296
320
|
}
|
|
@@ -347,17 +371,31 @@ export class PrismaAdapter {
|
|
|
347
371
|
if (existing && existing[this.scope.field] !== this.scope.value) {
|
|
348
372
|
throw new NotFoundError();
|
|
349
373
|
}
|
|
374
|
+
try {
|
|
375
|
+
return (await this.delegate.upsert({
|
|
376
|
+
where: { [this.idField]: id },
|
|
377
|
+
create: this.stampTenant(data),
|
|
378
|
+
update: this.stripTenant(data)
|
|
379
|
+
}));
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
if (isDuplicateError(error))
|
|
383
|
+
throw new ConflictError();
|
|
384
|
+
throw error;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
350
388
|
return (await this.delegate.upsert({
|
|
351
389
|
where: { [this.idField]: id },
|
|
352
|
-
create:
|
|
353
|
-
update:
|
|
390
|
+
create: data,
|
|
391
|
+
update: data
|
|
354
392
|
}));
|
|
355
393
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
if (isDuplicateError(error))
|
|
396
|
+
throw new ConflictError();
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
361
399
|
}
|
|
362
400
|
/**
|
|
363
401
|
* Deletes a single record identified by its ID. If the record does not exist, it returns false.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { HttpError } from './HttpError.js';
|
|
2
|
+
/** Thrown when a write conflicts with an existing record — e.g. a duplicate unique field (HTTP 409). */
|
|
3
|
+
export class ConflictError extends HttpError {
|
|
4
|
+
constructor(message = 'Conflict', details) {
|
|
5
|
+
super(message, 409, details);
|
|
6
|
+
this.name = 'ConflictError';
|
|
7
|
+
}
|
|
8
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from './core/types.js';
|
|
|
10
10
|
export * from './core/validation.js';
|
|
11
11
|
export * from '@edium/halifax-types';
|
|
12
12
|
export * from './errors/AuthenticationError.js';
|
|
13
|
+
export * from './errors/ConflictError.js';
|
|
13
14
|
export * from './errors/AuthorizationError.js';
|
|
14
15
|
export * from './errors/BadRequestError.js';
|
|
15
16
|
export * from './errors/HttpError.js';
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ export * from './core/types.js';
|
|
|
10
10
|
export * from './core/validation.js';
|
|
11
11
|
export * from '@edium/halifax-types';
|
|
12
12
|
export * from './errors/AuthenticationError.js';
|
|
13
|
+
export * from './errors/ConflictError.js';
|
|
13
14
|
export * from './errors/AuthorizationError.js';
|
|
14
15
|
export * from './errors/BadRequestError.js';
|
|
15
16
|
export * from './errors/HttpError.js';
|
|
@@ -262,6 +262,7 @@ const badRequestError = errorRef('Bad Request — malformed query string, invali
|
|
|
262
262
|
const notFoundError = errorRef('Not Found — the record with the given ID does not exist.');
|
|
263
263
|
const unprocessableError = errorRef('Unprocessable Entity — request body contains unknown or non-writable fields.');
|
|
264
264
|
const notImplementedError = errorRef('Not Implemented — the underlying repository does not support this operation.');
|
|
265
|
+
const conflictError = errorRef('Conflict — the write was rejected because it would violate a unique constraint.');
|
|
265
266
|
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
266
267
|
export function generateOpenApiSpec(resources, options = {}) {
|
|
267
268
|
const globalEnvelope = normalizeEnvelope(options.envelope);
|
|
@@ -458,6 +459,7 @@ export function generateOpenApiSpec(resources, options = {}) {
|
|
|
458
459
|
content: { 'application/json': { schema: { oneOf: [singleResponse, arrayResponse] } } }
|
|
459
460
|
},
|
|
460
461
|
'400': badRequestError,
|
|
462
|
+
'409': conflictError,
|
|
461
463
|
'422': unprocessableError,
|
|
462
464
|
...commonErrors,
|
|
463
465
|
...writeErrors
|
|
@@ -538,6 +540,7 @@ export function generateOpenApiSpec(resources, options = {}) {
|
|
|
538
540
|
}
|
|
539
541
|
},
|
|
540
542
|
'400': badRequestError,
|
|
543
|
+
'409': conflictError,
|
|
541
544
|
'422': unprocessableError,
|
|
542
545
|
'501': notImplementedError,
|
|
543
546
|
...commonErrors,
|
|
@@ -697,6 +700,7 @@ export function generateOpenApiSpec(resources, options = {}) {
|
|
|
697
700
|
},
|
|
698
701
|
'400': badRequestError,
|
|
699
702
|
'404': notFoundError,
|
|
703
|
+
'409': conflictError,
|
|
700
704
|
'422': unprocessableError,
|
|
701
705
|
...commonErrors,
|
|
702
706
|
...writeErrors
|
|
@@ -733,6 +737,7 @@ export function generateOpenApiSpec(resources, options = {}) {
|
|
|
733
737
|
}
|
|
734
738
|
},
|
|
735
739
|
'400': badRequestError,
|
|
740
|
+
'409': conflictError,
|
|
736
741
|
'422': unprocessableError,
|
|
737
742
|
'501': notImplementedError,
|
|
738
743
|
...commonErrors,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edium/halifax",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.3",
|
|
4
4
|
"description": "Auto-generate type-safe REST CRUD APIs from your data models. Adapter-driven: Express/Fastify/HyperExpress, Prisma (PostgreSQL, MySQL, MariaDB, SQL Server, CockroachDB, SQLite), JWT/API-key auth, multi-tenancy, a dynamic query builder, and pluggable Redis caching.",
|
|
5
5
|
"author": "David LaTour <david@edium.com>",
|
|
6
6
|
"homepage": "https://github.com/splayfee/halifax#readme",
|
|
@@ -166,10 +166,9 @@
|
|
|
166
166
|
],
|
|
167
167
|
"dependencies": {
|
|
168
168
|
"uuid": "^14.0.0",
|
|
169
|
-
"@edium/halifax-types": "2.2.
|
|
169
|
+
"@edium/halifax-types": "2.2.2"
|
|
170
170
|
},
|
|
171
171
|
"scripts": {
|
|
172
|
-
"preinstall": "node -e \"const ua = process.env.npm_config_user_agent; if (ua && !ua.startsWith('pnpm')) { console.error('\\nERROR: This repo requires pnpm. Run: pnpm install\\n'); process.exit(1); }\"",
|
|
173
172
|
"build": "rm -rf dist && tsc --build && tsc-alias",
|
|
174
173
|
"dev": "tsx watch ./examples/http-express.ts",
|
|
175
174
|
"lint": "eslint .",
|