Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/unique-violation-409.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
"@aws-blocks/bb-distributed-data": minor
"@aws-blocks/bb-data": minor
"@aws-blocks/blocks": patch
---

fix(data): map duplicate-key unique-constraint violations to JSON-RPC 409 (Conflict) instead of 500

A duplicate-key / unique-constraint violation (SQLSTATE `23505`,
`UniqueConstraintViolation`) now surfaces to clients as JSON-RPC error
**code 409 (Conflict)** instead of a generic **500**. Previously the engine
translators set `error.name` on the raw driver error and re-threw a plain named
`Error`; the JSON-RPC serializer maps any non-`ApiError` to 500, so a routine,
expected duplicate-key conflict was indistinguishable from an internal server
error. This mirrors the `40001`/OCC → 409 mapping already established for these
Blocks.

Each affected conflict is now an `ApiError` with `status: 409`, so on the client
`error.status === 409`. The structured `error.name` is preserved end-to-end, so
`isBlocksError(e, DatabaseErrors.UniqueConstraintViolation)` (and the
`DistributedDatabaseErrors` equivalent) keeps matching by name on both server and
client; the typed error constants are unchanged.

- `@aws-blocks/bb-data` — a duplicate-key violation (SQLSTATE `23505`,
`DatabaseErrors.UniqueConstraintViolation`) across the PGlite, pg-client, and
Data API engines (both the SQLState-parsed and message-matched Data API paths),
routed through a shared `uniqueConstraintConflict()` helper.
- `@aws-blocks/bb-distributed-data` — a DSQL duplicate-key violation (SQLSTATE
`23505`, `DistributedDatabaseErrors.UniqueConstraintViolation`) in
`translateDsqlError`, in both the mock and real engines.

The conflict is **not** flagged `retriable`: a duplicate key is deterministic, so
a blind retry of the same insert fails identically (unlike the `40001`
serialization failures, which stay retriable). The client-visible message is a
fixed, stable string; the raw driver text (which can name columns / constraints)
is retained only as `cause` for server-side diagnostics. Genuine infrastructure
errors (`ConnectionFailed`, `QueryFailed`) are unchanged and correctly stay 500,
and the `SerializationFailure`/OCC paths are untouched.

This is a `minor` bump. Both data packages are pre-1.0, where `minor` is this
repo's signal for a change that can alter existing behavior: callers that
branched on `error.status === 500` for these conflicts (or on the JSON-RPC error
code) will now see `409`. Code that matches conflicts by name via
`isBlocksError` — the documented pattern — is unaffected.

`@aws-blocks/blocks` gets a `patch` bump because it re-exports `bb-data` and
`bb-distributed-data` (satisfies the umbrella publish guard); no umbrella source
changed.

Fixes #508.
4 changes: 3 additions & 1 deletion packages/bb-data/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Error translation happens at the engine layer, not the database layer. Each engi
| pg error code | DatabaseErrors name |
|---------------|-------------------|
| `40001` | `SerializationFailure` (→ `ApiError` 409, retriable) |
| `23505` | `UniqueConstraintViolation` |
| `23505` | `UniqueConstraintViolation` (→ `ApiError` 409, not retriable) |
| `08xxx` | `ConnectionFailed` |
| (other) | `QueryFailed` |

Expand All @@ -64,6 +64,8 @@ The `DatabaseBase` subclass only adds `TransactionFailed` naming for errors that

An OCC serialization failure (SQLSTATE `40001`) is translated to an `ApiError` with status **409 (Conflict)**, retriable, preserving the `SerializationFailure` name so the JSON-RPC serializer emits 409 instead of a generic 500. This mapping is verified by translator/engine **unit tests** (`pg-error-translator.test.ts`, `data-api-engine.test.ts`), not an over-the-wire e2e test: the single-connection PGlite mock has no conflict-injection hook and cannot deterministically produce a `40001` serialization conflict over the wire, so unit tests are the verification ceiling for this Block. (The same holds for DistributedDatabase: its `40001` → 409 mapping is likewise covered by translator/engine unit tests only, not an over-the-wire e2e test — consistent with `bb-distributed-data/DESIGN.md`.)

A duplicate-key unique-constraint violation (SQLSTATE `23505`) is likewise translated to an `ApiError` with status **409 (Conflict)** — via the shared `uniqueConstraintConflict()` helper across the PGlite, pg-client, and Data API engines (both the SQLState-parsed and message-matched Data API paths) — preserving the `UniqueConstraintViolation` name. It is **not** retriable: a duplicate key is deterministic, so a blind retry of the same insert fails identically. Unlike `40001`, a `23505` conflict **is** deterministically inducible in the PGlite mock (which enforces the PK constraint), so this mapping is additionally verified by an **over-the-wire e2e test** (`test-apps/comprehensive/test/database.test.ts`) asserting `error.status === 409` on the client — per AGENTS.md §11 for a serialization/behavior-affecting change. The raw driver text is retained only as `cause` (server-side); the client message is a fixed string.

## RLS Implementation

`withRLS(context)` returns an `RLSScopedDatabase` that wraps every operation in a transaction with PostgreSQL session variables (using the standard `request.jwt.claims` / role convention):
Expand Down
3 changes: 2 additions & 1 deletion packages/bb-data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,8 @@ try {
await db.execute(sql`INSERT INTO users (id, email) VALUES (${id}, ${email})`);
} catch (e: unknown) {
if (isBlocksError(e, DatabaseErrors.UniqueConstraintViolation)) {
// Duplicate key — email already exists
// Duplicate key — email already exists. Serialized as HTTP 409 (Conflict),
// not retriable (a blind retry of the same insert fails identically).
}
if (isBlocksError(e, DatabaseErrors.QueryFailed)) {
// General query failure (syntax error, missing table, etc.)
Expand Down
23 changes: 16 additions & 7 deletions packages/bb-data/src/engines/data-api-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { test } from 'node:test';
import assert from 'node:assert';
import { ApiError } from '@aws-blocks/core';
import { DataApiEngine, toField, fromField } from './data-api-engine.js';
import { DatabaseErrors } from '../errors.js';

Expand Down Expand Up @@ -160,7 +161,7 @@ test('execute returns rowCount', async () => {

// --- error translation ---

test('BadRequestException with unique constraint maps to UniqueConstraintViolation', async () => {
test('BadRequestException with unique constraint maps to UniqueConstraintViolation (ApiError 409)', async () => {
const engine = createEngine({
ExecuteStatementCommand: () => {
const err = new Error('duplicate key value violates unique constraint');
Expand All @@ -170,14 +171,17 @@ test('BadRequestException with unique constraint maps to UniqueConstraintViolati
});
await assert.rejects(
() => engine.execute('INSERT INTO t VALUES (1)'),
(err: Error) => {
(err: unknown) => {
assert.ok(err instanceof ApiError, 'expected an ApiError');
assert.strictEqual(err.status, 409, 'duplicate key must be 409, not 500');
assert.strictEqual(err.name, DatabaseErrors.UniqueConstraintViolation);
assert.notStrictEqual(err.retriable, true);
return true;
}
);
});

test('non-BadRequestException with unique constraint message maps to UniqueConstraintViolation', async () => {
test('non-BadRequestException with unique constraint message maps to UniqueConstraintViolation (ApiError 409)', async () => {
const engine = createEngine({
ExecuteStatementCommand: () => {
const err = new Error('ERROR: duplicate key value violates unique constraint "t_pkey"; SQLState: 23505');
Expand All @@ -187,7 +191,9 @@ test('non-BadRequestException with unique constraint message maps to UniqueConst
});
await assert.rejects(
() => engine.execute('INSERT INTO t VALUES (1)'),
(err: Error) => {
(err: unknown) => {
assert.ok(err instanceof ApiError, 'expected an ApiError');
assert.strictEqual(err.status, 409);
assert.strictEqual(err.name, DatabaseErrors.UniqueConstraintViolation);
return true;
}
Expand Down Expand Up @@ -330,9 +336,9 @@ test('serialization failure surfaced on CommitTransaction (SQLState 40001) is cl
);
});

test('Data API unique violation (DatabaseErrorException, SQLState 23505) maps to UniqueConstraintViolation', async () => {
test('Data API unique violation (DatabaseErrorException, SQLState 23505) maps to UniqueConstraintViolation (ApiError 409)', async () => {
// Confirms the real exception name is DatabaseErrorException (not BadRequestException),
// and code-based classification keeps unique-violation mapping working.
// and code-based classification maps unique-violation to a 409 ApiError.
const engine = createEngine({
ExecuteStatementCommand: () => {
const err = new Error(
Expand All @@ -344,8 +350,11 @@ test('Data API unique violation (DatabaseErrorException, SQLState 23505) maps to
});
await assert.rejects(
() => engine.execute('INSERT INTO t VALUES ($1)', ['dup']),
(err: Error) => {
(err: unknown) => {
assert.ok(err instanceof ApiError, 'expected an ApiError');
assert.strictEqual(err.status, 409, 'duplicate key must be 409, not 500');
assert.strictEqual(err.name, DatabaseErrors.UniqueConstraintViolation);
assert.notStrictEqual(err.retriable, true);
return true;
},
);
Expand Down
11 changes: 8 additions & 3 deletions packages/bb-data/src/engines/data-api-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
type Field,
} from '@aws-sdk/client-rds-data';
import type { DatabaseEngine, TransactionHandle } from '@aws-blocks/data-common';
import { DatabaseErrors, TRANSIENT_DATA_API_ERROR_NAMES, wrapError, serializationConflict } from '../errors.js';
import { DatabaseErrors, TRANSIENT_DATA_API_ERROR_NAMES, wrapError, serializationConflict, uniqueConstraintConflict } from '../errors.js';

/**
* Translate `$1`, `$2`, ... placeholders to `:p1`, `:p2`, ... for Data API.
Expand Down Expand Up @@ -106,14 +106,19 @@ function translateError(e: unknown): never {
// error as cause. Matches the PGlite / pg-client engine paths.
throw serializationConflict(e);
} else if (code === '23505') {
e.name = DatabaseErrors.UniqueConstraintViolation;
// Duplicate key: surface as a 409 (Conflict), not a generic 500. Not
// retriable. Matches the PGlite / pg-client engine paths.
throw uniqueConstraintConflict(e);
} else if (code.startsWith('08')) {
e.name = DatabaseErrors.ConnectionFailed;
} else {
e.name = DatabaseErrors.QueryFailed;
}
} else if (/unique constraint|duplicate key/i.test(msg)) {
e.name = DatabaseErrors.UniqueConstraintViolation;
// Data API errors without a parseable SQLState still carry the driver's
// unique-violation text — map to the same 409 (Conflict) as the
// SQLState-parsed path above.
throw uniqueConstraintConflict(e);
} else if (TRANSIENT_DATA_API_ERROR_NAMES.has(e.name)) {
e.name = DatabaseErrors.ConnectionFailed;
} else {
Expand Down
23 changes: 14 additions & 9 deletions packages/bb-data/src/engines/pg-error-translator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@

/**
* Unit tests for the shared PostgreSQL error translator (used by PgClientEngine
* and PGliteEngine). Focus: an OCC / serialization-failure conflict (SQLSTATE
* 40001) must surface as an ApiError with HTTP status 409 (Conflict), not a
* generic 500, while preserving the SerializationFailure name and flagging the
* conflict retriable.
* and PGliteEngine). Focus: conflict codes must surface as an ApiError with an
* HTTP status (409 Conflict), not a generic 500 — an OCC / serialization-failure
* conflict (SQLSTATE 40001, retriable) and a duplicate-key / unique-constraint
* violation (SQLSTATE 23505, not retriable) — while preserving the standardized
* error name so `isBlocksError` keeps matching.
*/
import { test } from 'node:test';
import assert from 'node:assert';
Expand All @@ -28,14 +29,18 @@ test('translatePgError: serialization failure (40001) → ApiError status 409, r
);
});

test('translatePgError: unique violation (23505) → UniqueConstraintViolation (unchanged, non-ApiError)', () => {
const err = Object.assign(new Error('duplicate key'), { code: '23505' });
test('translatePgError: unique violation (23505) → ApiError status 409, name preserved, not retriable', () => {
const err = Object.assign(new Error('duplicate key value violates unique constraint "t_pkey"'), { code: '23505' });
assert.throws(
() => translatePgError(err, 'PgClientEngine'),
(e: unknown) => {
assert.ok(e instanceof Error);
assert.strictEqual((e as Error).name, DatabaseErrors.UniqueConstraintViolation);
assert.ok(!(e instanceof ApiError), 'unique violation should not be remapped to an ApiError');
assert.ok(e instanceof ApiError, 'expected an ApiError');
assert.strictEqual(e.status, 409);
assert.strictEqual(e.name, DatabaseErrors.UniqueConstraintViolation);
assert.notStrictEqual(e.retriable, true, 'a duplicate-key retry fails identically → not retriable');
// Raw driver error is retained server-side as `cause`, not leaked into the message.
assert.strictEqual(e.cause, err);
assert.notStrictEqual(e.message, err.message);
return true;
},
);
Expand Down
9 changes: 6 additions & 3 deletions packages/bb-data/src/engines/pg-error-translator.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { DatabaseErrors, wrapError, serializationConflict } from '../errors.js';
import { DatabaseErrors, wrapError, serializationConflict, uniqueConstraintConflict } from '../errors.js';

/** PostgreSQL error code for unique constraint violations. */
const PG_UNIQUE_VIOLATION = '23505';
Expand Down Expand Up @@ -30,8 +30,11 @@
throw serializationConflict(e);
}
if (code === PG_UNIQUE_VIOLATION) {
e.name = DatabaseErrors.UniqueConstraintViolation;
} else if (code && code.startsWith(PG_CONNECTION_EXCEPTION_CLASS)) {
// Duplicate key: surface as a 409 (Conflict), not a generic 500. Not
// retriable — a blind retry of the same insert fails identically.
throw uniqueConstraintConflict(e);
}
if (code && code.startsWith(PG_CONNECTION_EXCEPTION_CLASS)) {

Check warning on line 37 in packages/bb-data/src/engines/pg-error-translator.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/complexity/useOptionalChain

Change to an optional chain.
e.name = DatabaseErrors.ConnectionFailed;
} else {
e.name = DatabaseErrors.QueryFailed;
Expand Down
12 changes: 9 additions & 3 deletions packages/bb-data/src/engines/pglite-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@

import { test, afterEach } from 'node:test';
import assert from 'node:assert';
import { ApiError } from '@aws-blocks/core';
import { PGlite } from '@electric-sql/pglite';
import { PGliteEngine } from './pglite-engine.js';
import { DatabaseErrors } from '../errors.js';
import { rmSync, existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';

const TEST_DIR = '.bb-data-test-' + process.pid;

Check notice on line 13 in packages/bb-data/src/engines/pglite-engine.test.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/style/useTemplate

Template literals are preferred over string concatenation.
let engine: PGliteEngine;

afterEach(async () => {
Expand Down Expand Up @@ -78,13 +79,16 @@

// --- Core: error translation ---

test('duplicate key throws UniqueConstraintViolation', async () => {
test('duplicate key throws UniqueConstraintViolation as ApiError status 409', async () => {
await setup();
await engine.execute("INSERT INTO t (id, value) VALUES ('a', 'one')");
await assert.rejects(
() => engine.execute("INSERT INTO t (id, value) VALUES ('a', 'dupe')"),
(err: Error) => {
(err: unknown) => {
assert.ok(err instanceof ApiError, 'expected an ApiError');
assert.strictEqual(err.status, 409, 'duplicate key must be 409, not 500');
assert.strictEqual(err.name, DatabaseErrors.UniqueConstraintViolation);
assert.notStrictEqual(err.retriable, true, 'duplicate key is not retriable');
return true;
}
);
Expand Down Expand Up @@ -154,7 +158,9 @@
const handle = await engine.beginTransaction();
await assert.rejects(
() => engine.executeInTransaction(handle, "INSERT INTO t (id, value) VALUES ('a', 'dupe')"),
(err: Error) => {
(err: unknown) => {
assert.ok(err instanceof ApiError, 'expected an ApiError');
assert.strictEqual(err.status, 409);
assert.strictEqual(err.name, DatabaseErrors.UniqueConstraintViolation);
return true;
}
Expand Down
9 changes: 6 additions & 3 deletions packages/bb-data/src/engines/pglite-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { existsSync, mkdirSync, readdirSync, renameSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { initializePgliteWithRetry, type DatabaseEngine, type TransactionHandle } from '@aws-blocks/data-common';
import { DatabaseErrors, wrapError, serializationConflict } from '../errors.js';
import { DatabaseErrors, wrapError, serializationConflict, uniqueConstraintConflict } from '../errors.js';

/** PostgreSQL error code for unique constraint violations. */
const PG_UNIQUE_VIOLATION = '23505';
Expand Down Expand Up @@ -45,8 +45,11 @@
throw serializationConflict(e);
}
if (code === PG_UNIQUE_VIOLATION) {
e.name = DatabaseErrors.UniqueConstraintViolation;
} else if (code && code.startsWith(PG_CONNECTION_EXCEPTION_CLASS)) {
// Duplicate key: surface as a 409 (Conflict), not a generic 500. Not
// retriable — a blind retry of the same insert fails identically.
throw uniqueConstraintConflict(e);
}
if (code && code.startsWith(PG_CONNECTION_EXCEPTION_CLASS)) {

Check warning on line 52 in packages/bb-data/src/engines/pglite-engine.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/complexity/useOptionalChain

Change to an optional chain.
e.name = DatabaseErrors.ConnectionFailed;
} else {
e.name = DatabaseErrors.QueryFailed;
Expand Down
25 changes: 25 additions & 0 deletions packages/bb-data/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,31 @@ export function serializationConflict(cause: Error): ApiError {
});
}

/**
* Build the 409 ApiError for a unique-constraint / duplicate-key violation
* (SQLSTATE 23505). Maps to HTTP 409 (Conflict) so the JSON-RPC serializer emits
* code 409 instead of a generic 500, preserves the `UniqueConstraintViolation`
* name so `isBlocksError(e, DatabaseErrors.UniqueConstraintViolation)` keeps
* matching on both server and client, and keeps the original engine error as
* `cause` (server-side). Shared by every engine translator (PGlite, pg-client,
* Data API) so all paths produce an identically shaped 409.
*
* Unlike {@link serializationConflict}, this is NOT flagged retriable: a
* duplicate key is a deterministic constraint failure, so a blind retry of the
* same insert fails identically (ApiError defaults `retriable` to `false`).
*
* The client-visible message is a fixed, stable string; the raw driver text
* (which can name columns / constraint identifiers and varies by engine) is
* retained only as `cause` for server-side diagnostics, never interpolated into
* the message.
*/
export function uniqueConstraintConflict(cause: Error): ApiError {
return new ApiError('The item violates a unique constraint', 409, {
name: DatabaseErrors.UniqueConstraintViolation,
cause,
});
}

const knownErrors = new Set<string>(Object.values(DatabaseErrors));

/**
Expand Down
Loading
Loading