@edium/halifax 2.2.2 → 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 CHANGED
@@ -3,12 +3,39 @@
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
+
6
33
  ## [2.2.2]
7
34
 
8
35
  ### Fixed
9
36
 
10
37
  - 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.
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.
12
39
 
13
40
  ## [2.2.1]
14
41
 
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
 
@@ -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
- 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];
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
- const rows = (await this.db.insert(this.table).values(stamped).returning());
192
- return rows;
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
- const rows = (await this.db
198
- .update(this.table)
199
- .set(this.stripScope(data))
200
- .where(where)
201
- .returning());
202
- return rows[0] ?? null;
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
- return (await this.delegate.create({ data: this.stampTenant(data) }));
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
- await this.delegate.createMany({ data: data.map((item) => this.stampTenant(item)) });
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: this.stampTenant(data),
353
- update: this.stripTenant(data)
390
+ create: data,
391
+ update: data
354
392
  }));
355
393
  }
356
- return (await this.delegate.upsert({
357
- where: { [this.idField]: id },
358
- create: data,
359
- update: data
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.
@@ -13,6 +13,7 @@ const statusCodeMap = {
13
13
  404: 'NOT_FOUND',
14
14
  405: 'METHOD_NOT_ALLOWED',
15
15
  406: 'NOT_ACCEPTABLE',
16
+ 409: 'CONFLICT',
16
17
  415: 'UNSUPPORTED_MEDIA_TYPE',
17
18
  422: 'UNPROCESSABLE_ENTITY',
18
19
  501: 'NOT_IMPLEMENTED'
@@ -0,0 +1,5 @@
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 declare class ConflictError extends HttpError {
4
+ constructor(message?: string, details?: unknown);
5
+ }
@@ -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.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",