Skip to content

Commit da2a370

Browse files
committed
Standardize wallet limits, error envelope, and Prisma migration hygiene
1 parent 7ed0f8a commit da2a370

15 files changed

Lines changed: 384 additions & 36 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ jobs:
3636
- name: Generate Prisma client
3737
run: pnpm prisma:generate
3838

39+
- name: Check Prisma migration naming
40+
run: pnpm run prisma:check-migrations
41+
3942
- name: Build
4043
run: pnpm run build
4144

@@ -64,3 +67,6 @@ jobs:
6467

6568
- name: Test
6669
run: pnpm test
70+
71+
- name: Test scripts
72+
run: pnpm run test:scripts

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,31 @@ It handles wallet creation, transaction orchestration, fee sponsorship, and on-c
3535

3636
All routes below are served under the `/v1` prefix (e.g. `GET /v1/health`). See [docs/API-VERSIONING.md](docs/API-VERSIONING.md) for the versioning strategy.
3737

38+
### Error responses
39+
40+
Every error — thrown `HttpException`, unhandled exception, or validation
41+
failure — is returned by a global exception filter in the same structured
42+
envelope:
43+
44+
```json
45+
{
46+
"statusCode": 422,
47+
"timestamp": "2026-07-30T12:34:56.789Z",
48+
"path": "/v1/wallets/123/limits",
49+
"method": "POST",
50+
"message": "Per-transaction limit exceeded. Limit: 1000",
51+
"error": "Unprocessable Entity",
52+
"errorCode": "LIMIT_PER_TX_EXCEEDED",
53+
"requestId": "..."
54+
}
55+
```
56+
57+
`error` and `message` are always present. `errorCode` (a stable, machine-readable
58+
string) and `details` (a structured object) are included only when the thrown
59+
exception provides them. `requestId` is echoed back from the `X-Request-ID`
60+
request header when present. In production, `message` on unhandled 500 errors
61+
is sanitized to strip connection strings, file paths, and secrets.
62+
3863
### Request body size
3964

4065
JSON and URL-encoded request bodies are limited to 100 KiB by default. Set

docs/PRISMA-MIGRATIONS.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Prisma migration conventions
2+
3+
## Naming
4+
5+
Every migration must live in its own folder directly under `prisma/migrations/`:
6+
7+
```
8+
prisma/migrations/20260730120000_add_thing/migration.sql
9+
```
10+
11+
- Folder name: `<14-digit-timestamp>_<snake_case_description>` — the format
12+
`prisma migrate dev` generates by default. The timestamp must be unique and
13+
should reflect when the migration was authored (`YYYYMMDDHHMMSS`).
14+
- The folder must contain a `migration.sql` file. Don't drop loose `.sql`
15+
files directly under `prisma/migrations/` — Prisma silently ignores
16+
anything that isn't inside a migration folder, so a stray file never gets
17+
applied by `prisma migrate deploy` even though it looks like it's part of
18+
the migration history.
19+
- `migration_lock.toml` is the only file allowed directly under
20+
`prisma/migrations/`.
21+
22+
A number of early migrations predate this convention — some use a bare
23+
counter (`0_init`), a short date without a time component
24+
(`20260602_add_wallet_key_version`), or reuse the same 14-digit timestamp as
25+
another migration. They're already applied in every environment, so renaming
26+
them would break Prisma's `_prisma_migrations` tracking table. They're listed
27+
by name in the `LEGACY_EXCEPTIONS` set in `scripts/check-migration-naming.ts`
28+
(the source of truth) and must not be used as a template for new migrations.
29+
30+
## CI check
31+
32+
`pnpm run prisma:check-migrations` (wired into `.github/workflows/ci.yml`)
33+
verifies:
34+
35+
- no loose files under `prisma/migrations/` other than `migration_lock.toml`
36+
- every migration folder contains a `migration.sql`
37+
- every non-legacy folder matches the naming pattern above
38+
- no two non-legacy migrations reuse the same timestamp
39+
40+
Run it locally before opening a PR that touches `prisma/migrations/`:
41+
42+
```
43+
pnpm run prisma:check-migrations
44+
```
45+
46+
The validation logic is unit tested in `scripts/check-migration-naming.spec.ts`
47+
(`pnpm run test:scripts`).

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525
"test:e2e": "jest --config ./test/jest-e2e.json",
2626
"preinstall": "npx only-allow pnpm",
2727
"openapi:generate": "ts-node -r tsconfig-paths/register scripts/generate-openapi.ts",
28-
"openapi:lint": "npx @redocly/cli@latest lint openapi.json --config redocly.yaml"
28+
"openapi:lint": "npx @redocly/cli@latest lint openapi.json --config redocly.yaml",
29+
"prisma:check-migrations": "ts-node -r tsconfig-paths/register scripts/check-migration-naming.ts",
30+
"test:scripts": "jest --config scripts/jest.config.js"
2931
},
3032
"dependencies": {
3133
"@nestjs/common": "^11.0.1",

prisma/migrations/network_scoped_api_keys.sql renamed to prisma/migrations/20260730000000_add_network_scoped_api_keys/migration.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
-- AlterTable
2-
ALTER TABLE "ApiKey" ADD COLUMN "network" TEXT;
2+
ALTER TABLE "ApiKey" ADD COLUMN "network" "WalletNetwork";
33

44
-- CreateIndex
55
CREATE INDEX "ApiKey_network_idx" ON "ApiKey"("network");
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { validateMigrationEntries, MigrationEntry } from './check-migration-naming';
2+
3+
function dir(name: string, hasMigrationSql = true): MigrationEntry {
4+
return { name, isDirectory: true, hasMigrationSql };
5+
}
6+
7+
function file(name: string): MigrationEntry {
8+
return { name, isDirectory: false, hasMigrationSql: false };
9+
}
10+
11+
describe('validateMigrationEntries', () => {
12+
it('passes for a well-formed set of migrations plus the lock file', () => {
13+
const errors = validateMigrationEntries([
14+
file('migration_lock.toml'),
15+
dir('20260601000000_add_thing'),
16+
dir('20260602000000_add_other_thing'),
17+
]);
18+
expect(errors).toEqual([]);
19+
});
20+
21+
it('grandfathers known legacy folder names without a timestamp prefix', () => {
22+
const errors = validateMigrationEntries([
23+
dir('0_init'),
24+
dir('1_add_wallet_limit'),
25+
dir('20260602_add_wallet_key_version'),
26+
]);
27+
expect(errors).toEqual([]);
28+
});
29+
30+
it('fails on a loose file directly under prisma/migrations', () => {
31+
const errors = validateMigrationEntries([
32+
file('network_scoped_api_keys.sql'),
33+
]);
34+
expect(errors).toEqual([
35+
expect.stringContaining('network_scoped_api_keys.sql'),
36+
]);
37+
});
38+
39+
it('fails on a migration folder missing migration.sql', () => {
40+
const errors = validateMigrationEntries([
41+
dir('20260601000000_add_thing', false),
42+
]);
43+
expect(errors).toEqual([
44+
expect.stringContaining('missing a migration.sql file'),
45+
]);
46+
});
47+
48+
it('fails on a new (non-legacy) folder that does not match the naming pattern', () => {
49+
const errors = validateMigrationEntries([dir('add_thing_without_timestamp')]);
50+
expect(errors).toEqual([
51+
expect.stringContaining('does not match the required'),
52+
]);
53+
});
54+
55+
it('fails on a non-legacy folder using an unpadded/short timestamp', () => {
56+
const errors = validateMigrationEntries([dir('20260602_add_wallet_key_version_v2')]);
57+
expect(errors.length).toBe(1);
58+
expect(errors[0]).toContain('does not match the required');
59+
});
60+
61+
it('fails when two non-legacy migrations reuse the same timestamp', () => {
62+
const errors = validateMigrationEntries([
63+
dir('20260601000000_add_thing'),
64+
dir('20260601000000_add_other_thing'),
65+
]);
66+
expect(errors).toEqual([
67+
expect.stringContaining('reuses timestamp 20260601000000'),
68+
]);
69+
});
70+
71+
it('does not flag duplicate timestamps between two legacy-exception folders', () => {
72+
const errors = validateMigrationEntries([
73+
dir('0_init'),
74+
dir('1_add_wallet_limit'),
75+
]);
76+
expect(errors).toEqual([]);
77+
});
78+
});

scripts/check-migration-naming.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
4+
export const MIGRATIONS_DIR = path.join(__dirname, '..', 'prisma', 'migrations');
5+
6+
/** Files permitted to sit directly under prisma/migrations/ (not inside a migration folder). */
7+
export const ALLOWED_TOP_LEVEL_FILES = new Set(['migration_lock.toml']);
8+
9+
/**
10+
* Migration folders created before this naming convention was enforced.
11+
* They are already applied to real databases, so they can't be renamed
12+
* without breaking Prisma's `_prisma_migrations` tracking table. New
13+
* migrations must not be added to this list.
14+
*/
15+
export const LEGACY_EXCEPTIONS = new Set([
16+
'0_init',
17+
'1_add_wallet_limit',
18+
'20260601000000_add_spending_limits',
19+
'20260601000000_add_transaction_idempotency_key',
20+
'20260601000000_add_wallet_successor_id',
21+
'20260602_add_wallet_key_version',
22+
'20260723_add_asset_code_to_payment',
23+
'20260724000000_add_user_default_network',
24+
'20260724000000_add_user_last_login_metadata',
25+
'20260724_add_soft_delete_to_wallet_limit',
26+
'20260729000000_add_maintenance_state',
27+
'20260729000000_add_wallet_nickname',
28+
]);
29+
30+
const NAME_PATTERN = /^\d{14}_[a-z0-9_]+$/;
31+
32+
export interface MigrationEntry {
33+
name: string;
34+
isDirectory: boolean;
35+
hasMigrationSql: boolean;
36+
}
37+
38+
/**
39+
* Pure validation over a directory listing so the rules can be unit tested
40+
* without touching the filesystem.
41+
*/
42+
export function validateMigrationEntries(entries: MigrationEntry[]): string[] {
43+
const errors: string[] = [];
44+
const seenTimestamps = new Map<string, string>();
45+
46+
for (const entry of entries) {
47+
if (!entry.isDirectory) {
48+
if (!ALLOWED_TOP_LEVEL_FILES.has(entry.name)) {
49+
errors.push(
50+
`"${entry.name}" is a loose file directly under prisma/migrations/. ` +
51+
`Every migration must live in its own "<timestamp>_<name>/migration.sql" folder.`,
52+
);
53+
}
54+
continue;
55+
}
56+
57+
if (!entry.hasMigrationSql) {
58+
errors.push(
59+
`"${entry.name}/" is missing a migration.sql file.`,
60+
);
61+
}
62+
63+
if (LEGACY_EXCEPTIONS.has(entry.name)) {
64+
continue;
65+
}
66+
67+
if (!NAME_PATTERN.test(entry.name)) {
68+
errors.push(
69+
`"${entry.name}" does not match the required "<14-digit-timestamp>_<snake_case_name>" ` +
70+
`format (e.g. 20260730120000_add_thing). See docs/PRISMA-MIGRATIONS.md.`,
71+
);
72+
continue;
73+
}
74+
75+
const timestamp = entry.name.slice(0, 14);
76+
const clash = seenTimestamps.get(timestamp);
77+
if (clash) {
78+
errors.push(
79+
`"${entry.name}" reuses timestamp ${timestamp} already used by "${clash}". ` +
80+
`Migration timestamps must be unique and monotonically increasing.`,
81+
);
82+
} else {
83+
seenTimestamps.set(timestamp, entry.name);
84+
}
85+
}
86+
87+
return errors;
88+
}
89+
90+
function readEntries(dir: string): MigrationEntry[] {
91+
return fs.readdirSync(dir).map((name) => {
92+
const full = path.join(dir, name);
93+
const isDirectory = fs.statSync(full).isDirectory();
94+
const hasMigrationSql =
95+
isDirectory && fs.existsSync(path.join(full, 'migration.sql'));
96+
return { name, isDirectory, hasMigrationSql };
97+
});
98+
}
99+
100+
function main() {
101+
const entries = readEntries(MIGRATIONS_DIR);
102+
const errors = validateMigrationEntries(entries);
103+
104+
if (errors.length > 0) {
105+
console.error('Prisma migration naming check failed:\n');
106+
for (const error of errors) {
107+
console.error(` - ${error}`);
108+
}
109+
console.error(
110+
'\nSee docs/PRISMA-MIGRATIONS.md for the naming convention and how to fix this.',
111+
);
112+
process.exit(1);
113+
}
114+
115+
console.log(`Prisma migration naming check passed (${entries.length} entries).`);
116+
}
117+
118+
if (require.main === module) {
119+
main();
120+
}

scripts/jest.config.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
module.exports = {
2+
rootDir: '.',
3+
testRegex: '.*\\.spec\\.ts$',
4+
transform: {
5+
'^.+\\.ts$': 'ts-jest',
6+
},
7+
testEnvironment: 'node',
8+
};

src/common/filters/http-exception.filter.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,27 @@ describe('HttpExceptionFilter', () => {
388388
const jsonCall = mockResponse.json.mock.calls[0][0];
389389
expect(jsonCall.details).toBeUndefined();
390390
});
391+
392+
it('should include errorCode field when provided on the exception body', () => {
393+
const exception = new HttpException(
394+
{ errorCode: 'LIMIT_PER_TX_EXCEEDED', message: 'Per-transaction limit exceeded' },
395+
HttpStatus.UNPROCESSABLE_ENTITY,
396+
);
397+
398+
filter.catch(exception, mockArgumentsHost);
399+
400+
const jsonCall = mockResponse.json.mock.calls[0][0];
401+
expect(jsonCall.errorCode).toBe('LIMIT_PER_TX_EXCEEDED');
402+
});
403+
404+
it('should not include errorCode field when not provided', () => {
405+
const exception = new NotFoundException('Not found');
406+
407+
filter.catch(exception, mockArgumentsHost);
408+
409+
const jsonCall = mockResponse.json.mock.calls[0][0];
410+
expect(jsonCall.errorCode).toBeUndefined();
411+
});
391412
});
392413

393414
describe('HTTP status code mapping', () => {

src/common/filters/http-exception.filter.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface ErrorResponse {
1818
method: string;
1919
message: string | string[];
2020
error?: string;
21+
errorCode?: string;
2122
details?: Record<string, any>;
2223
requestId?: string;
2324
}
@@ -69,10 +70,8 @@ export class HttpExceptionFilter implements ExceptionFilter {
6970
const exceptionResponse = exception.getResponse();
7071

7172
// Extract message and details from exception response
72-
const { message, error, details } = this.parseHttpExceptionResponse(
73-
exceptionResponse,
74-
status,
75-
);
73+
const { message, error, errorCode, details } =
74+
this.parseHttpExceptionResponse(exceptionResponse, status);
7675

7776
return {
7877
statusCode: status,
@@ -81,6 +80,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
8180
method,
8281
message,
8382
error,
83+
...(errorCode && { errorCode }),
8484
...(details && { details }),
8585
...(request.headers['x-request-id'] && {
8686
requestId: request.headers['x-request-id'] as string,
@@ -126,6 +126,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
126126
): {
127127
message: string | string[];
128128
error: string;
129+
errorCode?: string;
129130
details?: Record<string, any>;
130131
} {
131132
// If response is a string, use it as the message
@@ -142,6 +143,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
142143
return {
143144
message: responseObj.message || 'An error occurred',
144145
error: responseObj.error || this.getErrorNameFromStatus(status),
146+
...(responseObj.errorCode && { errorCode: responseObj.errorCode }),
145147
...(responseObj.details && { details: responseObj.details }),
146148
};
147149
}

0 commit comments

Comments
 (0)