Skip to content

feat(errors): @constructive-io/errors package + server code normalization & class-based masking#1415

Open
pyramation wants to merge 2 commits into
mainfrom
feat/constructive-errors
Open

feat(errors): @constructive-io/errors package + server code normalization & class-based masking#1415
pyramation wants to merge 2 commits into
mainfrom
feat/constructive-errors

Conversation

@pyramation

@pyramation pyramation commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Phase 1 of the Constructive error system (plan issue #1235): a standalone, zero-dependency @constructive-io/errors package that is the single source of truth for error codes, messages, i18n, and cross-source parsing — plus wiring it into the GraphQL server so database error codes actually reach clients as structured extensions.code, and pgpm's old error factory now consumes it.

Forward-only: the package owns the canonical shape; no backwards-compat shims.

packages/errors (new)

A registry-driven system. Each code is defined once with its class/HTTP/message; factories, formatting, and classification derive from it.

defineError<{ name: string }>({ code: 'MODULE_NOT_FOUND', class: 'internal', http: 404,
  message: 'Module "{{name}}" not found in modules list.' });

parse(anyError)                 // pg DatabaseError | GraphQLError | { errors:[...] } | Error | string → { code, context, class, known }
format(code, context, locale)   // {{var}} interpolation + registerable per-locale catalogs (i18n)
classify(code)                  // 'public' | 'internal'  (unknown ⇒ internal, fail-safe)
errors.MODULE_NOT_FOUND({ name })   // type-safe throwable ConstructiveError
  • parse() recovers a code by precedence: structured DETAIL JSON ({"code","context"}) → GraphQL extensions.code → leading ALL_CAPS message token incl. legacy dynamic CODE (arg, arg) (mapped onto named context via a per-code positional list) → native SQLSTATE constraint mapping (23505UNIQUE_VIOLATION, …). Unknown ⇒ known:false, class:'internal'.
  • 21 unit tests cover parsing, classification, factory, formatting, i18n fallback, and the full generated registry.

Full registry — every constructive-db error collected (287 codes)

The registry is now two layers merged at lookup time (curated wins):

  • Generated src/generated/registry.generated.tsall 287 codes raised via EXCEPTION/THROW across constructive-db (deploy sources + generated output, incl. the 16 that only appear in generated SQL). Each carries a public/internal class, HTTP hint, and default message; public copy is seeded from the dashboard error catalogs where a matching code exists, developer/invariant codes keep their raw message (%-args rendered as {{argN}}). Split: 83 public / 204 internal (unknown/uncertain fail safe to internal, so they stay masked).
  • Curated src/registry.ts — refined, typed-context entries (public auth/limit copy, native pg constraint aliases, pgpm CLI codes) that override the generated ones.

Reproducible, not hand-maintained:

python3 packages/errors/scripts/generate-registry.py

reads a committed audit snapshot (scripts/db-error-inventory.json) + optional dashboard checkout → regenerates the generated layer. GENERATED_CODE_META records each code's class/dynamic/generatedOnly for auditing.

graphql/servermaskError now normalizes + masks by class

The bug (confirmed empirically in Phase 0): DB errors reach Grafast with an empty extensions — the semantic code lives only in the message (and any SQLSTATE/DETAIL on originalError). So the old SAFE_ERROR_CODES allowlist, which matched on extensions.code, never matched DB errors.

- if (dev) return error;
- if (SAFE_ERROR_CODES.has(error.extensions?.code)) return error;   // never true for DB errors
- return masked;
+ const { code, context, class } = normalizeError(error);   // parse(originalError) ?? parse(error)
+ // lift the recovered code onto extensions.code/class/context (extensions is read-only ⇒ build a formatted error)
+ if (isPublicCode(code) || dev) return enrichedError;       // isPublic = registry public OR transitional allowlist
+ return masked;                                             // internal/unknown ⇒ reference id + log

Net effect: clients now always get a machine-readable extensions.code for recognized errors (the real win), public errors pass through in prod, internal/unknown stay masked. SAFE_ERROR_CODES is retained as a transitional allowlist (GraphQL protocol + not-yet-registered auth codes) until the registry is generated.

pgpm/types — factory becomes a thin consumer

error-factory.ts (previously ~148 lines of inline English + a loose makeError) is now just:

export { errors, makeError } from '@constructive-io/errors';

Call sites (throw errors.MODULE_NOT_FOUND({ name }), errors.DEPLOYMENT_FAILED({ type, module }), …) are unchanged; messages/context types match the old definitions. PgpmError is left in place for anything importing it directly.

Validation

  • @constructive-io/errors build + tests: 21/21 pass.
  • graphql/server and @pgpmjs/types builds: green.
  • pnpm install --frozen-lockfile: consistent (lockfile edited by hand to add only the new package + two workspace:^ links, avoiding a full lockfile rewrite by the local pnpm).

Later phases (separate PRs): add structured RAISE ... USING ERRCODE, DETAIL=<json> upstream in constructive-db (so context travels without string-parsing), convert user-facing phrase-only throws into codes, and migrate the dashboard/blocks consumers onto the package (retiring the transitional SAFE_ERROR_CODES allowlist and the duplicated client error systems).

Link to Devin session: https://app.devin.ai/sessions/f4d3ce9225894883ba9cfc9ed2f0ba7e
Requested by: @pyramation

Add a standalone, zero-dependency @constructive-io/errors package (canonical registry, parse/format/classify, i18n, type-safe factory), make pgpm consume it, and normalize DB codes into GraphQL extensions with class-based masking.
@pyramation pyramation self-assigned this Jul 22, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

Collect every constructive-db EXCEPTION/THROW code (287) into a generated
registry layer with public/internal class, HTTP hint, and message (public copy
seeded from dashboard catalogs). Curated typed entries override generated ones.
Reproducible via scripts/generate-registry.py from a committed audit snapshot.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant