Skip to content

feat(effect-app,infra): run jitM at the store boundary as JSON → JSON - #896

Merged
patroza merged 2 commits into
mainfrom
feat/jitm-json-store-boundary
Sep 16, 2026
Merged

patroza merged 2 commits into
mainfrom
feat/jitm-json-store-boundary

Conversation

@patroza

@patroza patroza commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

Supersedes #894 and #895 — both will be closed. This is the same design rebuilt directly on main, with nothing lenient.

The bug

Since #874 ("native Date/Map/Set Encoded"), the document stores decode every stored document with the full schema at the store boundary, and the repository applied jitM (mapFrom) only afterwards. So a legacy document that jitM exists to migrate could not be read at all:

SchemaError: Missing key
  at ["vatRate"]

on every find / filter / all / validateSample. Found via macs-holding/configurator, where all 7 DB Validation jobs fail against real production data: Shop.vatRate missing on every shop, User.permissions on most users, Configurator.conditionGroups[].conditions[].rules[].groupId on legacy rules. Read-only prod inspection confirmed these are genuinely missing keys — there are no nulls involved.

jitM was also mistyped: (pm: Encoded) => Encoded promised native Date/Map/Set, while every jitM is written against the document as stored.

The pipeline

store: raw JSON -> merge defaultValues -> jitM (JSON -> JSON) -> decode toCodecJson(toEncoded(schema)) => Encoded
repo:  Encoded -> decode schema => domain type
// effect-app/Store
export type JsonRecord = { readonly [key: string]: Schema.Json }
interface StoreConfig<E> {
  defaultValues?: Partial<E>               // unchanged: Encoded, lowered by the adapters
  jitM?: (json: JsonRecord) => JsonRecord  // new
}
// effect-app/Model/Repository — RepositoryOptions
jitM?: (json: JsonRecord) => JsonRecord    // was (pm: Encoded) => Encoded

makeRepo forwards jitM into the store config; makeRepoInternal loses its mapFrom parameter and mapReverse only splits _etag; validateSample decodes the store's output directly. New infra helper makeStoredDecode(codec, jitM) holds the read boundary in one place, splitting _etag off first so jitM never sees infrastructure metadata. Wired in Cosmos, SQL, Pg, Memory and Disk (Disk at the file-read boundary, exactly once, since jitMs are not guaranteed idempotent). Never on the write path.

Everything stays strict. makeJsonDocumentCodec is unchanged from main in both directions, and packages/infra/src/Store/utils.ts is byte-identical to main. A document jitM does not repair fails loudly — pinned by a test, with a comment telling future readers not to "fix" it into a lenient decode.

defaultValues keep their Partial<Encoded> type and their existing lowering via json.toJson(...); they still fill only absent keys, so an explicitly stored null reaches jitM — which is the point: null is a value, not a missing key, and only jitM can decide what to do with it.

Breaking changes for consumers

  1. jitM is (json: JsonRecord) => JsonRecord, no longer generic over Encoded.
  2. Values arrive as JSON: Date → ISO string, ReadonlySet → array, ReadonlyMap → array of [k, v] pairs. Returning a Date/Map/Set is now wrong.
  3. Index-signature access (json["x"]); "x" in json still works.
  4. _etag is not part of the document passed to jitM.
  5. ValidationError.jitMResult is @deprecated and equals rawData — the repository can no longer observe a pre-jitM document. Kept rather than removed to avoid breaking consumers; happy to delete it instead.
// add a missing key — before → after
jitM: (pm: typeof Shop.Encoded) => "vatRate" in pm ? pm : { ...pm, vatRate: 19 }
jitM: (json) => "vatRate" in json ? json : { ...json, vatRate: 19 }

// a Date field — before → after (ISO strings both sides; fresh: new Date().toISOString())
jitM: (pm) => pm.updatedAt ? pm : { ...pm, updatedAt: pm.createdAt }
jitM: (json) => json["updatedAt"] ? json : { ...json, updatedAt: json["createdAt"] }

Verification

  • Root pnpm check (tsgo): clean, no casts added to paper over the mapFrom removal.
  • packages/infra: 282 passed / 26 skipped. packages/effect-app: 185 passed. Lint clean.
  • 15 tests across repository-legacy-document.test.ts (new) and validateSample.test.ts: missing-key repair through find/all/validateSample; explicit null repair; jitM sees JSON (ISO string, Set-as-array, Map-as-pairs, no _etag); defaultValues fill only absent keys with a stored null reaching jitM; an unrepaired document failing loudly; _etag invisible to jitM and preserved.

Notes

  • MemoryStoreLive cannot seed a legacy-shaped document (its seed path strictly encodes the whole document), so the legacy-document test uses a schemaless Memory harness with the real read boundary on top. Worth deciding separately whether writes should fail loudly or lower leniently.
  • Projections/select and Cosmos queryRaw bypass the document decode and therefore do not run jitM. Pre-existing, unchanged.
  • defaultValues on read remain inconsistent across adapters (Cosmos/SQL merge on every read; Memory at seed time; Disk before its inner store). Left alone deliberately.
  • A strict store decode surfaces as a defect, because makeJsonDocumentCodec.decode is sync and throws. Turning that into a typed SchemaError failure needs the read paths effectified (Store.find/all/filter channels widened, per-document decode returning Result, adapters' maps lifted) — planned as a separate PR.

🤖 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.

patroza and others added 2 commits September 16, 2026 08:35
`jitM` was applied by the repository *after* the store had already decoded the
document, and was typed `(pm: Encoded) => Encoded` - a lie, since jitMs are
written against the stored JSON and `Encoded` holds native Date/Map/Set.

The read pipeline is now: raw JSON document -> merge `defaultValues` (unchanged:
they only fill absent keys, so an explicitly stored `null` reaches `jitM`) ->
`jitM` (JSON -> JSON) -> decode `toCodecJson(toEncoded(schema))` -> Encoded. The
repository only decodes Encoded -> the domain type, so `jitM` can repair legacy
shapes - including explicit `null`s - before any schema decode sees the document.

The JSON->Encoded decode stays strict: `jitM` is the one place a legacy document
can be repaired, and a document it does not repair fails loudly at the store
boundary rather than being read back half-decoded.

- `StoreConfig.jitM?: (json: JsonRecord) => JsonRecord` is new, alongside the
  exported `JsonRecord` alias; it is not applied on the write/encode path and
  never receives `_etag`
- `RepositoryOptions.jitM` changes to the same signature and is forwarded into
  the store config instead of into `makeRepoInternal`
- `makeRepoInternal` loses its `mapFrom` parameter; `mapReverse` now only splits
  `_etag` and calls `setEtag`
- `Disk` applies `jitM` once at the file-read boundary and passes `undefined`
  down to its inner Memory store, since jitMs are not guaranteed idempotent
- `validateSample` decodes the store's output directly; `ValidationError.jitMResult`
  is deprecated as it is identical to `rawData`

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add a legacy-document suite for the JSON-shaped `jitM` running at the store
boundary, covering what the pipeline makes possible and what it deliberately
refuses:

- `jitM` repairs a legacy document missing a required key, which decodes
  end-to-end through `find`/`all`/`validateSample`
- `jitM` repairs an explicit `null` (not a valid `S.Date` encoding, so no schema
  decode could ever fix it) and the document decodes
- `jitM` receives the raw JSON document - a Date as an ISO string, a ReadonlySet
  as an array, a ReadonlyMap as an array of pairs, and no `_etag` - and the JSON
  it returns is what the decode consumes
- `defaultValues` still only fill absent keys, are merged before `jitM` runs, and
  a stored `null` reaches `jitM` instead of being replaced by the default
- a document `jitM` does *not* repair fails loudly at the store boundary
  (`SchemaError: Missing key at ["vatRate"]`), pinned down so it is not "fixed"
  into a lenient decode later
- `_etag` is not visible to `jitM` and survives the round trip

`MemoryStoreLive` cannot seed a legacy document (it strictly `encode`s its seed),
so the suite uses a schemaless Memory harness that applies the real read boundary
on top.

`validateSample`'s corruption cases move to a schema whose Encoded shape is
looser than its domain type (`FiniteFromString`): now that the store decode is
strict, a `jitM` producing a value the Encoded shape rejects would fail in the
store rather than surface as a repository `ValidationError`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@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@896

effect-app

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

@effect-app/eslint-codegen-model

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

@effect-app/eslint-shared-config

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

@effect-app/infra

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

@effect-app/vue

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

@effect-app/vue-components

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

commit: 74b3133

@patroza
patroza merged commit 2ebf8ae into main Sep 16, 2026
6 checks passed
@patroza
patroza deleted the feat/jitm-json-store-boundary branch September 16, 2026 06:44
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