Skip to content

feat(effect-app,infra): fail store document decode as a typed SchemaError - #898

Merged
patroza merged 2 commits into
mainfrom
feat/store-decode-schema-error
Sep 16, 2026
Merged

patroza merged 2 commits into
mainfrom
feat/store-decode-schema-error

Conversation

@patroza

@patroza patroza commented Sep 16, 2026

Copy link
Copy Markdown
Member

Follow-up to #896 (merged). Phase 1 of making store decode failures inspectable: the store's document decode now fails as a typed S.SchemaError instead of throwing a defect, the failure travels up through the repository internals, and the public Repository signatures are unchanged — they die at one explicit boundary.

Why

makeJsonDocumentCodec.decode is S.decodeSync, so a document jitM does not repair throws inside adapter map callbacks. Two consequences:

  • validateSample — whose whole job is reporting bad documents — dies instead of recording one. Diagnosing a real case (a container holding only libs' own seed marker) meant reproducing it by hand to learn which key was missing.
  • Nothing downstream can inspect the failure: it isn't in any error channel.

Nothing here becomes lenient. An unrepaired document still fails; it fails typed.

Shape

export type StoredDecode<E extends FieldValues> =
  (doc: PersistenceModelType<E>) => Result.Result<PersistenceModelType<E>, S.SchemaError>

export const makeStoredDecode: <E extends FieldValues>(
  schema: StoreConfig<E>["schema"],
  jitM: StoreConfig<E>["jitM"]
) => StoredDecode<E>

Result, not Exit: S.decodeUnknownResult gives a two-case tagged value with no Cause allocation, Effect.fromResult lifts it in one step, and validateSample already inspects decode outcomes as Result. Helpers decodeStoredMany (sync fail-fast loop, one Result per batch) and decodeStoredOption sit beside it.

Deliberately synchronous. The decode runs once per document over whole result sets, so there is no Effect per row: each read operation runs one sync loop that fails fast and lifts once. All annotateDb/span/timeSchema instrumentation is preserved — the decode moved from Effect.map into Effect.flatMap inside the same annotated pipelines.

Channels

Store members that decode now declare DatabaseError | SchemaError, each with a one-line reason: all, find, FilterFunc/filter, queryRaw, and the write members that decode the written document on the way back (set, batchSet, bulkSet — Memory maps decodeDoc there). batchRemove/seedNamespace unchanged.

Model/Repository/service.ts is not in this diff — no public signature changed. The single boundary is internal/internal.ts where the public members are produced: find, all, saveAndPublish, removeById each Effect.catchTag("SchemaError", (e) => Effect.die(e)). Deliberately catchTag and not orDie, which would have eaten DatabaseError as well. queryRaw, query and mapped already declared S.SchemaError and simply propagate.

The payoff

validateSample now distinguishes the two failure sources: a store-boundary failure arrives in store.find's error channel (captured with Effect.result, narrowed with S.isSchemaError, a DatabaseError re-failed) and is recorded as ValidationError{ id, rawData: undefined, error } — undefined because the store failed before it could hand back a document; its own decode failure keeps rawData. Either way the loop continues, so one bad document no longer costs the whole run.

Verification

  • Root pnpm check (tsgo): clean; no new casts.
  • packages/infra: 284 passed / 26 skipped. packages/effect-app: 185 passed. Lint clean.
  • Tests: the "fails loudly" case now asserts a typed SchemaError via Effect.result (a defect would escape it); a new test asserts the same document still dies through repo.all (Cause.hasDies true, hasFails false), proving the public surface is unchanged; another asserts validateSample returns valid: 1 with one ValidationError naming the bad document and no death.

Two judgement calls for your review

  1. makeStoredDecode now takes the store's schema rather than the JsonDocumentCodec, because building a non-throwing decoder needs S.decodeUnknownResult (the codec only exposes decodeSync). makeJsonDocumentCodec is unchanged and still used by the write path; both share one internal jsonCodec helper.
  2. Disk's file-read decode fails typed, but I kept its existing Effect.orDie: that read seeds the in-memory store and makeDiskStoreInt is already orDie'd by both callers, so a typed failure there cannot reach any Store member.

Also: SQL/Pg parseRow lost its decode parameter and is now purely "row → raw JSON document", with the adapters composing decodeStored(parseRow(...)). That keeps all 9 existing parseRow tests compiling untouched.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

@pkg-pr-new

pkg-pr-new Bot commented Sep 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

@effect-app/cli

npm i https://pkg.pr.new/effect-app/libs/@effect-app/cli@898

effect-app

npm i https://pkg.pr.new/effect-app/libs/effect-app@898

@effect-app/eslint-codegen-model

npm i https://pkg.pr.new/effect-app/libs/@effect-app/eslint-codegen-model@898

@effect-app/eslint-shared-config

npm i https://pkg.pr.new/effect-app/libs/@effect-app/eslint-shared-config@898

@effect-app/infra

npm i https://pkg.pr.new/effect-app/libs/@effect-app/infra@898

@effect-app/vue

npm i https://pkg.pr.new/effect-app/libs/@effect-app/vue@898

@effect-app/vue-components

npm i https://pkg.pr.new/effect-app/libs/@effect-app/vue-components@898

commit: 764e79d

patroza and others added 2 commits September 16, 2026 10:11
…emaError

`makeStoredDecode` now decodes with `S.decodeUnknownResult` and returns a
`Result`, so a document `jitM` does not repair is a typed `S.SchemaError`
instead of a defect thrown out of `decodeSync`. Nothing became lenient: the
same documents still fail, only inspectably.

The decode stays synchronous - one loop per operation, fail-fast, lifted once
with `Effect.fromResult` - so no Effect is allocated per document. Cosmos, SQL,
SQL/Pg, Memory and Disk lift it into their channel, and `Store`'s decoding
members declare `DatabaseError | SchemaError`.

The repository internals let that failure propagate and die on it where the
public members are produced, so `Repository.find`/`all`/`saveAndPublish`/
`removeById` keep their declared error types. `validateSample` now records a
store-boundary failure as a `ValidationError` for that document and keeps
sampling, instead of losing the whole run to a defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dary

The "fails loudly" case now asserts a typed `S.SchemaError` from the store
(via `Effect.result`, which a defect would escape), plus a new test that the
same document still dies on a public repository read, and one that
`validateSample` reports it as a `ValidationError` while the good document
still validates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@patroza
patroza force-pushed the feat/store-decode-schema-error branch from 11cae16 to 764e79d Compare September 16, 2026 08:11
@patroza
patroza merged commit 3a8c710 into main Sep 16, 2026
6 checks passed
@patroza
patroza deleted the feat/store-decode-schema-error branch September 16, 2026 08:16
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