@open-mercato/shared 0.6.7-develop.6552.1.5208a19369 → 0.6.7-develop.6565.1.1026d85fff
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/AGENTS.md +24 -0
- package/dist/lib/crud/errors.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/crud/__tests__/errors.test.ts +47 -1
- package/src/lib/crud/errors.ts +23 -0
- package/src/modules/perspectives/types.ts +2 -0
package/AGENTS.md
CHANGED
|
@@ -168,6 +168,30 @@ import { hasFeature, hasAllFeatures } from '@open-mercato/shared/security/featur
|
|
|
168
168
|
- Use `hasAllFeatures(granted, required)` for arrays such as `features`, `requireFeatures`, or handler guard lists.
|
|
169
169
|
- MUST NOT gate raw feature arrays with `includes(...)`, `Set.has(...)`, or ad hoc `every(...includes(...))` checks in shared registries or runners; wildcard grants like `module.*` and `*` are part of the RBAC contract.
|
|
170
170
|
|
|
171
|
+
### CRUD HTTP Errors — MUST use the shared helpers instead of hand-rolling `CrudHttpError`
|
|
172
|
+
|
|
173
|
+
`@open-mercato/shared/lib/crud/errors` owns the standardized error shapes. Use the helper, not a raw `new CrudHttpError(...)`:
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
import { assertFound, notFound, badRequest, forbidden, conflict } from '@open-mercato/shared/lib/crud/errors'
|
|
177
|
+
|
|
178
|
+
const deal = assertFound(await em.findOne(Deal, { id }), translate('customers.errors.deal_not_found', 'Deal not found'))
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
| Status | Helper |
|
|
182
|
+
|--------|--------|
|
|
183
|
+
| 400 | `badRequest(message)` |
|
|
184
|
+
| 403 | `forbidden(message?)` |
|
|
185
|
+
| 404 | `notFound(message?)` — or `assertFound(value, message)` when guarding a lookup |
|
|
186
|
+
| 409 | `conflict(message)` |
|
|
187
|
+
|
|
188
|
+
MUST rules:
|
|
189
|
+
- MUST NOT hand-roll `throw new CrudHttpError(404, { error: msg })`. Pick the helper that fits the call site:
|
|
190
|
+
- **Inline lookup** → `const deal = assertFound(await em.findOne(Deal, { id }), msg)`. It throws the standardized 404 and returns the value narrowed to `T`, so there is no nullable intermediate binding.
|
|
191
|
+
- **Guard statement** (multi-line lookup, or a compound condition such as `if (!entity || entity.tenantId !== auth.tenantId)`) → `throw notFound(msg)`. TypeScript already narrows the value after the throw, so this keeps tenant-scoping conditions explicit without losing type safety.
|
|
192
|
+
- `message` is passed through verbatim and is never derived from an entity name — keep routing 404 copy through `translate(...)` so it stays translatable.
|
|
193
|
+
- `assertFound` treats every falsy value as missing. Use it for entity/object lookups only, never to guard numbers or strings where `0`/`''` are valid results.
|
|
194
|
+
|
|
171
195
|
### CRUD Multi-ID Filtering
|
|
172
196
|
|
|
173
197
|
- Use `parseIdsParam()` and `mergeIdFilter()` from `@open-mercato/shared/lib/crud/ids` for factory-level `ids` query support.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/crud/errors.ts"],
|
|
4
|
-
"sourcesContent": ["// Use Symbol.for so the marker survives module duplication across bundle boundaries\n// (same behaviour as globalThis-based registries used for DI registrars)\nconst CRUD_HTTP_ERROR_MARKER = Symbol.for('@open-mercato/CrudHttpError')\n\nexport class CrudHttpError extends Error {\n readonly [CRUD_HTTP_ERROR_MARKER] = true\n status: number\n body: Record<string, any>\n\n constructor(\n status: number,\n body?: Record<string, any> | string,\n options?: { cause?: unknown },\n ) {\n const normalizedBody = typeof body === 'string' ? { error: body } : body ?? {}\n super(typeof body === 'string' ? body : normalizedBody.error ?? 'Request failed', options)\n this.status = status\n this.body = normalizedBody\n }\n}\n\n/**\n * Type-safe check for CrudHttpError that works across module/bundle boundaries.\n * Prefer this over `instanceof CrudHttpError` whenever the error may originate\n * from a different module bundle (e.g. enterprise packages, dynamic imports).\n */\nexport function isCrudHttpError(err: unknown): err is CrudHttpError {\n return !!err && typeof err === 'object' && (err as Record<symbol, unknown>)[CRUD_HTTP_ERROR_MARKER] === true\n}\n\nexport function badRequest(message: string): CrudHttpError {\n return new CrudHttpError(400, { error: message })\n}\n\nexport function forbidden(message = 'Forbidden'): CrudHttpError {\n return new CrudHttpError(403, { error: message })\n}\n\nexport function notFound(message = 'Not found'): CrudHttpError {\n return new CrudHttpError(404, { error: message })\n}\n\nexport function conflict(message: string): CrudHttpError {\n return new CrudHttpError(409, { error: message })\n}\n\nconst POSTGRES_UNIQUE_VIOLATION = '23505'\n\n/**\n * Detects a Postgres unique-constraint violation (SQLSTATE 23505) on a thrown\n * error, looking through MikroORM's driver-error wrapping. Use this to map a DB\n * uniqueness race (e.g. a soft-deleted row the in-app check missed, or a\n * timestamp-precision mismatch) onto a clean 409 instead of a generic 500.\n */\nexport function isUniqueViolation(err: unknown, constraintName?: string): boolean {\n if (!err || typeof err !== 'object') return false\n const candidates: unknown[] = [err, (err as { cause?: unknown }).cause, (err as { previous?: unknown }).previous]\n for (const candidate of candidates) {\n if (!candidate || typeof candidate !== 'object') continue\n const record = candidate as Record<string, unknown>\n if (record.code === POSTGRES_UNIQUE_VIOLATION) {\n if (!constraintName) return true\n const constraint = typeof record.constraint === 'string' ? record.constraint : ''\n const detail = typeof record.detail === 'string' ? record.detail : ''\n const message = typeof record.message === 'string' ? record.message : ''\n return constraint === constraintName || detail.includes(constraintName) || message.includes(constraintName)\n }\n }\n return false\n}\n\nexport function assertFound<T>(value: T | null | undefined, message: string): T {\n if (!value) throw notFound(message)\n return value\n}\n"],
|
|
5
|
-
"mappings": "AAAA;AAEA,MAAM,yBAAyB,uBAAO,IAAI,6BAA6B;AAEhE,MAAM,uBAAsB,YACvB,6BADuB,IAAM;AAAA,EAKvC,YACE,QACA,MACA,SACA;AACA,UAAM,iBAAiB,OAAO,SAAS,WAAW,EAAE,OAAO,KAAK,IAAI,QAAQ,CAAC;AAC7E,UAAM,OAAO,SAAS,WAAW,OAAO,eAAe,SAAS,kBAAkB,OAAO;AAV3F,SAAU,MAA0B;AAWlC,SAAK,SAAS;AACd,SAAK,OAAO;AAAA,EACd;AACF;AAOO,SAAS,gBAAgB,KAAoC;AAClE,SAAO,CAAC,CAAC,OAAO,OAAO,QAAQ,YAAa,IAAgC,sBAAsB,MAAM;AAC1G;AAEO,SAAS,WAAW,SAAgC;AACzD,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAEO,SAAS,UAAU,UAAU,aAA4B;AAC9D,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;
|
|
4
|
+
"sourcesContent": ["// Use Symbol.for so the marker survives module duplication across bundle boundaries\n// (same behaviour as globalThis-based registries used for DI registrars)\nconst CRUD_HTTP_ERROR_MARKER = Symbol.for('@open-mercato/CrudHttpError')\n\nexport class CrudHttpError extends Error {\n readonly [CRUD_HTTP_ERROR_MARKER] = true\n status: number\n body: Record<string, any>\n\n constructor(\n status: number,\n body?: Record<string, any> | string,\n options?: { cause?: unknown },\n ) {\n const normalizedBody = typeof body === 'string' ? { error: body } : body ?? {}\n super(typeof body === 'string' ? body : normalizedBody.error ?? 'Request failed', options)\n this.status = status\n this.body = normalizedBody\n }\n}\n\n/**\n * Type-safe check for CrudHttpError that works across module/bundle boundaries.\n * Prefer this over `instanceof CrudHttpError` whenever the error may originate\n * from a different module bundle (e.g. enterprise packages, dynamic imports).\n */\nexport function isCrudHttpError(err: unknown): err is CrudHttpError {\n return !!err && typeof err === 'object' && (err as Record<symbol, unknown>)[CRUD_HTTP_ERROR_MARKER] === true\n}\n\nexport function badRequest(message: string): CrudHttpError {\n return new CrudHttpError(400, { error: message })\n}\n\nexport function forbidden(message = 'Forbidden'): CrudHttpError {\n return new CrudHttpError(403, { error: message })\n}\n\n/**\n * Builds the standardized 404 (`{ error: message }`) \u2014 use it instead of hand-rolling\n * `new CrudHttpError(404, { error: message })`. Reach for this in guard statements, where\n * the lookup spans several lines or the condition is compound\n * (`if (!entity || entity.tenantId !== auth.tenantId) throw notFound(msg)`); TypeScript\n * still narrows the value after the throw. For a plain inline lookup, `assertFound` is terser.\n *\n * Pass an already-translated message; this helper never derives one, so 404 copy\n * stays routed through i18n.\n */\nexport function notFound(message = 'Not found'): CrudHttpError {\n return new CrudHttpError(404, { error: message })\n}\n\nexport function conflict(message: string): CrudHttpError {\n return new CrudHttpError(409, { error: message })\n}\n\nconst POSTGRES_UNIQUE_VIOLATION = '23505'\n\n/**\n * Detects a Postgres unique-constraint violation (SQLSTATE 23505) on a thrown\n * error, looking through MikroORM's driver-error wrapping. Use this to map a DB\n * uniqueness race (e.g. a soft-deleted row the in-app check missed, or a\n * timestamp-precision mismatch) onto a clean 409 instead of a generic 500.\n */\nexport function isUniqueViolation(err: unknown, constraintName?: string): boolean {\n if (!err || typeof err !== 'object') return false\n const candidates: unknown[] = [err, (err as { cause?: unknown }).cause, (err as { previous?: unknown }).previous]\n for (const candidate of candidates) {\n if (!candidate || typeof candidate !== 'object') continue\n const record = candidate as Record<string, unknown>\n if (record.code === POSTGRES_UNIQUE_VIOLATION) {\n if (!constraintName) return true\n const constraint = typeof record.constraint === 'string' ? record.constraint : ''\n const detail = typeof record.detail === 'string' ? record.detail : ''\n const message = typeof record.message === 'string' ? record.message : ''\n return constraint === constraintName || detail.includes(constraintName) || message.includes(constraintName)\n }\n }\n return false\n}\n\n/**\n * The canonical \"entity must exist\" guard for API routes and commands: throws the\n * standardized 404 when the lookup came back empty, and returns the value narrowed\n * to `T` so callers drop the `| null` without a cast.\n *\n * const deal = assertFound(await em.findOne(Deal, { id }), translate('customers.errors.deal_not_found', 'Deal not found'))\n *\n * Use this instead of hand-rolling `if (!entity) throw new CrudHttpError(404, ...)`.\n * `message` is required and passed through verbatim, so it must already be translated.\n *\n * Treats every falsy value as missing, so it suits entity/object lookups \u2014 do not use\n * it to guard numbers or strings, where `0` and `''` are legitimate results.\n */\nexport function assertFound<T>(value: T | null | undefined, message: string): T {\n if (!value) throw notFound(message)\n return value\n}\n"],
|
|
5
|
+
"mappings": "AAAA;AAEA,MAAM,yBAAyB,uBAAO,IAAI,6BAA6B;AAEhE,MAAM,uBAAsB,YACvB,6BADuB,IAAM;AAAA,EAKvC,YACE,QACA,MACA,SACA;AACA,UAAM,iBAAiB,OAAO,SAAS,WAAW,EAAE,OAAO,KAAK,IAAI,QAAQ,CAAC;AAC7E,UAAM,OAAO,SAAS,WAAW,OAAO,eAAe,SAAS,kBAAkB,OAAO;AAV3F,SAAU,MAA0B;AAWlC,SAAK,SAAS;AACd,SAAK,OAAO;AAAA,EACd;AACF;AAOO,SAAS,gBAAgB,KAAoC;AAClE,SAAO,CAAC,CAAC,OAAO,OAAO,QAAQ,YAAa,IAAgC,sBAAsB,MAAM;AAC1G;AAEO,SAAS,WAAW,SAAgC;AACzD,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAEO,SAAS,UAAU,UAAU,aAA4B;AAC9D,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAYO,SAAS,SAAS,UAAU,aAA4B;AAC7D,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAEO,SAAS,SAAS,SAAgC;AACvD,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAEA,MAAM,4BAA4B;AAQ3B,SAAS,kBAAkB,KAAc,gBAAkC;AAChF,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO;AAC5C,QAAM,aAAwB,CAAC,KAAM,IAA4B,OAAQ,IAA+B,QAAQ;AAChH,aAAW,aAAa,YAAY;AAClC,QAAI,CAAC,aAAa,OAAO,cAAc,SAAU;AACjD,UAAM,SAAS;AACf,QAAI,OAAO,SAAS,2BAA2B;AAC7C,UAAI,CAAC,eAAgB,QAAO;AAC5B,YAAM,aAAa,OAAO,OAAO,eAAe,WAAW,OAAO,aAAa;AAC/E,YAAM,SAAS,OAAO,OAAO,WAAW,WAAW,OAAO,SAAS;AACnE,YAAM,UAAU,OAAO,OAAO,YAAY,WAAW,OAAO,UAAU;AACtE,aAAO,eAAe,kBAAkB,OAAO,SAAS,cAAc,KAAK,QAAQ,SAAS,cAAc;AAAA,IAC5G;AAAA,EACF;AACA,SAAO;AACT;AAeO,SAAS,YAAe,OAA6B,SAAoB;AAC9E,MAAI,CAAC,MAAO,OAAM,SAAS,OAAO;AAClC,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.6565.1.1026d85fff'\nexport const appVersion = APP_VERSION\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6565.1.1026d85fff",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"@mikro-orm/core": "^7.1.5",
|
|
98
98
|
"@mikro-orm/decorators": "^7.1.5",
|
|
99
99
|
"@mikro-orm/postgresql": "^7.1.5",
|
|
100
|
-
"@open-mercato/cache": "0.6.7-develop.
|
|
100
|
+
"@open-mercato/cache": "0.6.7-develop.6565.1.1026d85fff",
|
|
101
101
|
"@types/sanitize-html": "^2.16.1",
|
|
102
102
|
"dotenv": "^17.4.2",
|
|
103
103
|
"pino": "^10.3.1",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CrudHttpError, isCrudHttpError } from '../errors'
|
|
1
|
+
import { CrudHttpError, isCrudHttpError, assertFound, notFound } from '../errors'
|
|
2
2
|
|
|
3
3
|
describe('CrudHttpError', () => {
|
|
4
4
|
it('builds from string body', () => {
|
|
@@ -34,3 +34,49 @@ describe('CrudHttpError', () => {
|
|
|
34
34
|
expect(err.cause).toBeUndefined()
|
|
35
35
|
})
|
|
36
36
|
})
|
|
37
|
+
|
|
38
|
+
describe('notFound', () => {
|
|
39
|
+
it('builds the standardized 404 body', () => {
|
|
40
|
+
const err = notFound('Deal not found')
|
|
41
|
+
expect(err.status).toBe(404)
|
|
42
|
+
expect(err.body).toEqual({ error: 'Deal not found' })
|
|
43
|
+
expect(isCrudHttpError(err)).toBe(true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('falls back to a generic message', () => {
|
|
47
|
+
expect(notFound().body).toEqual({ error: 'Not found' })
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('assertFound', () => {
|
|
52
|
+
it('returns the value when present', () => {
|
|
53
|
+
const deal = { id: 'deal-1' }
|
|
54
|
+
expect(assertFound(deal, 'Deal not found')).toBe(deal)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('narrows away null and undefined', () => {
|
|
58
|
+
const lookup = (): { id: string } | null => ({ id: 'deal-1' })
|
|
59
|
+
const deal: { id: string } = assertFound(lookup(), 'Deal not found')
|
|
60
|
+
expect(deal.id).toBe('deal-1')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it.each([[null], [undefined]])('throws the standardized 404 for %p', (missing) => {
|
|
64
|
+
expect(() => assertFound(missing, 'Deal not found')).toThrow(CrudHttpError)
|
|
65
|
+
try {
|
|
66
|
+
assertFound(missing, 'Deal not found')
|
|
67
|
+
} catch (err) {
|
|
68
|
+
expect(isCrudHttpError(err)).toBe(true)
|
|
69
|
+
expect((err as CrudHttpError).status).toBe(404)
|
|
70
|
+
expect((err as CrudHttpError).body).toEqual({ error: 'Deal not found' })
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('passes the message through verbatim so translated copy survives', () => {
|
|
75
|
+
const translated = 'Umowa nie została znaleziona'
|
|
76
|
+
try {
|
|
77
|
+
assertFound(null, translated)
|
|
78
|
+
} catch (err) {
|
|
79
|
+
expect((err as CrudHttpError).body).toEqual({ error: translated })
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
})
|
package/src/lib/crud/errors.ts
CHANGED
|
@@ -36,6 +36,16 @@ export function forbidden(message = 'Forbidden'): CrudHttpError {
|
|
|
36
36
|
return new CrudHttpError(403, { error: message })
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Builds the standardized 404 (`{ error: message }`) — use it instead of hand-rolling
|
|
41
|
+
* `new CrudHttpError(404, { error: message })`. Reach for this in guard statements, where
|
|
42
|
+
* the lookup spans several lines or the condition is compound
|
|
43
|
+
* (`if (!entity || entity.tenantId !== auth.tenantId) throw notFound(msg)`); TypeScript
|
|
44
|
+
* still narrows the value after the throw. For a plain inline lookup, `assertFound` is terser.
|
|
45
|
+
*
|
|
46
|
+
* Pass an already-translated message; this helper never derives one, so 404 copy
|
|
47
|
+
* stays routed through i18n.
|
|
48
|
+
*/
|
|
39
49
|
export function notFound(message = 'Not found'): CrudHttpError {
|
|
40
50
|
return new CrudHttpError(404, { error: message })
|
|
41
51
|
}
|
|
@@ -69,6 +79,19 @@ export function isUniqueViolation(err: unknown, constraintName?: string): boolea
|
|
|
69
79
|
return false
|
|
70
80
|
}
|
|
71
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The canonical "entity must exist" guard for API routes and commands: throws the
|
|
84
|
+
* standardized 404 when the lookup came back empty, and returns the value narrowed
|
|
85
|
+
* to `T` so callers drop the `| null` without a cast.
|
|
86
|
+
*
|
|
87
|
+
* const deal = assertFound(await em.findOne(Deal, { id }), translate('customers.errors.deal_not_found', 'Deal not found'))
|
|
88
|
+
*
|
|
89
|
+
* Use this instead of hand-rolling `if (!entity) throw new CrudHttpError(404, ...)`.
|
|
90
|
+
* `message` is required and passed through verbatim, so it must already be translated.
|
|
91
|
+
*
|
|
92
|
+
* Treats every falsy value as missing, so it suits entity/object lookups — do not use
|
|
93
|
+
* it to guard numbers or strings, where `0` and `''` are legitimate results.
|
|
94
|
+
*/
|
|
72
95
|
export function assertFound<T>(value: T | null | undefined, message: string): T {
|
|
73
96
|
if (!value) throw notFound(message)
|
|
74
97
|
return value
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export type PerspectiveSettings = {
|
|
2
2
|
columnOrder?: string[]
|
|
3
3
|
columnVisibility?: Record<string, boolean>
|
|
4
|
+
/** User-resized column widths in pixels, keyed by column id (#1835). */
|
|
5
|
+
columnSizing?: Record<string, number>
|
|
4
6
|
filters?: Record<string, unknown>
|
|
5
7
|
sorting?: Array<{ id: string; desc?: boolean }>
|
|
6
8
|
pageSize?: number
|