From b804f24855d573e83e8c80e182dca1c5501caddd Mon Sep 17 00:00:00 2001 From: jfet97 Date: Mon, 8 Dec 2025 10:33:33 +0100 Subject: [PATCH 1/2] feat(infra): add validateSample method to Repository for schema validation testing --- packages/infra/package.json | 4 + packages/infra/src/Model/Repository.ts | 1 + .../src/Model/Repository/internal/internal.ts | 62 ++++- .../infra/src/Model/Repository/service.ts | 12 + .../infra/src/Model/Repository/validation.ts | 31 +++ packages/infra/test/validateSample.test.ts | 237 ++++++++++++++++++ 6 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 packages/infra/src/Model/Repository/validation.ts create mode 100644 packages/infra/test/validateSample.test.ts diff --git a/packages/infra/package.json b/packages/infra/package.json index 160824097..8fa8de6da 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -114,6 +114,10 @@ "types": "./dist/Model/Repository/service.d.ts", "default": "./dist/Model/Repository/service.js" }, + "./Model/Repository/validation": { + "types": "./dist/Model/Repository/validation.d.ts", + "default": "./dist/Model/Repository/validation.js" + }, "./Model/dsl": { "types": "./dist/Model/dsl.d.ts", "default": "./dist/Model/dsl.js" diff --git a/packages/infra/src/Model/Repository.ts b/packages/infra/src/Model/Repository.ts index 687ba9134..f86208a69 100644 --- a/packages/infra/src/Model/Repository.ts +++ b/packages/infra/src/Model/Repository.ts @@ -2,3 +2,4 @@ export * from "./Repository/ext.js" export * from "./Repository/legacy.js" export { makeRepo } from "./Repository/makeRepo.js" export * from "./Repository/service.js" +export * from "./Repository/validation.js" diff --git a/packages/infra/src/Model/Repository/internal/internal.ts b/packages/infra/src/Model/Repository/internal/internal.ts index 8f4083d47..aca5b7101 100644 --- a/packages/infra/src/Model/Repository/internal/internal.ts +++ b/packages/infra/src/Model/Repository/internal/internal.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type {} from "effect/Equal" import type {} from "effect/Hash" -import { Array, Chunk, Context, Effect, Equivalence, flow, type NonEmptyReadonlyArray, Option, pipe, Pipeable, PubSub, S, Unify } from "effect-app" +import { Array, Chunk, Context, Effect, Either, Equivalence, flow, type NonEmptyReadonlyArray, Option, pipe, Pipeable, PubSub, S, Unify } from "effect-app" import { toNonEmptyArray } from "effect-app/Array" import { NotFoundError } from "effect-app/client/errors" import { flatMapOption } from "effect-app/Effect" @@ -12,6 +12,7 @@ import { getContextMap } from "../../../Store/ContextMapContainer.js" import type { FieldValues } from "../../filter/types.js" import * as Q from "../../query.js" import type { Repository } from "../service.js" +import { ValidationError, ValidationResult } from "../validation.js" const dedupe = Array.dedupeWith(Equivalence.string) @@ -322,6 +323,64 @@ export function makeRepoInternal< ) }) as any + const validateSample = Effect.fn("validateSample")(function*(options?: { + percentage?: number + maxItems?: number + }) { + const percentage = options?.percentage ?? 0.1 // default 10% + const maxItems = options?.maxItems + + // 1. get all IDs with projection (bypasses main schema decode) + const allIds = yield* store.filter({ + t: null as unknown as Encoded, + select: [idKey as keyof Encoded] + }) + + // 2. random subset + const shuffled = [...allIds].sort(() => Math.random() - 0.5) + const sampleSize = Math.min( + maxItems ?? Infinity, + Math.ceil(allIds.length * percentage) + ) + const sample = shuffled.slice(0, sampleSize) + + // 3. validate each item + const errors: ValidationError[] = [] + + for (const item of sample) { + const id = item[idKey] + const rawResult = yield* store.find(id) + + if (Option.isNone(rawResult)) continue + + const rawData = rawResult.value as Encoded + const jitMResult = mapFrom(rawData) // apply jitM + + const decodeResult = yield* S.decode(schema)(jitMResult).pipe( + Effect.either, + provideRctx + ) + + if (Either.isLeft(decodeResult)) { + errors.push( + new ValidationError({ + id, + rawData, + jitMResult, + error: decodeResult.left + }) + ) + } + } + + return new ValidationResult({ + total: NonNegativeInt(allIds.length), + sampled: NonNegativeInt(sample.length), + valid: NonNegativeInt(sample.length - errors.length), + errors + }) + }) + const r: Repository, RPublish> = { changeFeed, itemType: name, @@ -331,6 +390,7 @@ export function makeRepoInternal< saveAndPublish, removeAndPublish, removeById, + validateSample, queryRaw(schema, q) { const dec = S.decode(S.Array(schema)) return store.queryRaw(q).pipe(Effect.flatMap(dec)) diff --git a/packages/infra/src/Model/Repository/service.ts b/packages/infra/src/Model/Repository/service.ts index 2595665c4..3dfd9a419 100644 --- a/packages/infra/src/Model/Repository/service.ts +++ b/packages/infra/src/Model/Repository/service.ts @@ -5,6 +5,7 @@ import type { NonNegativeInt } from "effect-app/Schema/numbers" import type { FieldValues, IsNever, ResolveFirstLevel } from "../filter/types.js" import type { QAll, Query, QueryProjection, RawQuery } from "../query.js" import type { Mapped } from "./legacy.js" +import type { ValidationResult } from "./validation.js" export interface Repository< T, @@ -535,6 +536,17 @@ export interface Repository< /** @deprecated use query */ readonly mapped: Mapped + + /** + * Validates a random sample of repository items by applying jitM and schema decode. + * Useful for testing that migrations and schema changes work correctly on existing data. + */ + readonly validateSample: (options?: { + /** percentage of items to sample (0.0-1.0), default 0.1 (10%) */ + percentage?: number + /** optional maximum number of items to sample */ + maxItems?: number + }) => Effect.Effect } type DistributeQueryIfExclusiveOnArray = [Exclusive] extends [true] diff --git a/packages/infra/src/Model/Repository/validation.ts b/packages/infra/src/Model/Repository/validation.ts new file mode 100644 index 000000000..183819a48 --- /dev/null +++ b/packages/infra/src/Model/Repository/validation.ts @@ -0,0 +1,31 @@ +import { S } from "effect-app" +import { NonNegativeInt } from "effect-app/Schema" + +/** + * Represents a single validation error when decoding a repository item. + * Contains full context for debugging: raw data, jitM result, and decode error. + */ +export class ValidationError extends S.Class("@effect-app/infra/ValidationError")({ + /** the id of the item that failed validation */ + id: S.Unknown, + /** the raw data from the database before jitM */ + rawData: S.Unknown, + /** the data after applying jitM transformation */ + jitMResult: S.Unknown, + /** the ParseResult.ParseError from schema decode */ + error: S.Unknown +}) {} + +/** + * Result of validating a sample of repository items. + */ +export class ValidationResult extends S.Class("@effect-app/infra/ValidationResult")({ + /** total number of items in the repository */ + total: NonNegativeInt, + /** number of items that were sampled for validation */ + sampled: NonNegativeInt, + /** number of items that passed validation */ + valid: NonNegativeInt, + /** list of validation errors with full context */ + errors: S.Array(ValidationError) +}) {} diff --git a/packages/infra/test/validateSample.test.ts b/packages/infra/test/validateSample.test.ts new file mode 100644 index 000000000..997932b17 --- /dev/null +++ b/packages/infra/test/validateSample.test.ts @@ -0,0 +1,237 @@ +import { Effect, S } from "effect-app" +import { describe, expect, it } from "vitest" +import { setupRequestContextFromCurrent } from "../src/api/setupRequest.js" +import { makeRepo, ValidationError, ValidationResult } from "../src/Model/Repository.js" +import { MemoryStoreLive } from "../src/Store/Memory.js" + +// simple schema for valid items +class SimpleItem extends S.Class("SimpleItem")({ + id: S.String, + name: S.NonEmptyString255, + count: S.NonNegativeInt +}) {} + +describe("validateSample", () => { + it("returns success when all items pass validation", () => + Effect + .gen(function*() { + const repo = yield* makeRepo("SimpleItem", SimpleItem, { + makeInitial: Effect.succeed([ + new SimpleItem({ id: "1", name: S.NonEmptyString255("Alice"), count: S.NonNegativeInt(10) }), + new SimpleItem({ id: "2", name: S.NonEmptyString255("Bob"), count: S.NonNegativeInt(20) }), + new SimpleItem({ id: "3", name: S.NonEmptyString255("Charlie"), count: S.NonNegativeInt(30) }) + ]) + }) + + const result = yield* repo.validateSample({ percentage: 1.0 }) // 100% + + expect(result).toBeInstanceOf(ValidationResult) + expect(result.total).toBe(3) + expect(result.sampled).toBe(3) + expect(result.valid).toBe(3) + expect(result.errors).toHaveLength(0) + }) + .pipe( + Effect.provide(MemoryStoreLive), + setupRequestContextFromCurrent(), + Effect.runPromise + )) + + it("returns errors when jitM produces invalid data", () => + Effect + .gen(function*() { + // jitM that corrupts one specific item's count to be negative + const corruptingJitM = (pm: typeof SimpleItem.Encoded) => { + if (pm.id === "2" || pm.id === "3") { + return { ...pm, count: -999 } // make count negative (invalid for NonNegativeInt) + } + return pm + } + + const repo = yield* makeRepo("CorruptItem", SimpleItem, { + jitM: corruptingJitM, + makeInitial: Effect.succeed([ + new SimpleItem({ id: "1", name: S.NonEmptyString255("Valid"), count: S.NonNegativeInt(10) }), + new SimpleItem({ id: "2", name: S.NonEmptyString255("WillBeInvalid1"), count: S.NonNegativeInt(20) }), + new SimpleItem({ id: "3", name: S.NonEmptyString255("WillBeInvalid2"), count: S.NonNegativeInt(30) }) + ]) + }) + + const result = yield* repo.validateSample({ percentage: 1.0 }) // 100% + + expect(result).toBeInstanceOf(ValidationResult) + expect(result.total).toBe(3) + expect(result.sampled).toBe(3) + expect(result.valid).toBe(1) + expect(result.errors).toHaveLength(2) + + // verify error structure + for (const error of result.errors) { + expect(error).toBeInstanceOf(ValidationError) + expect(error.id).toBeDefined() + expect(error.rawData).toBeDefined() + expect(error.jitMResult).toBeDefined() + expect(error.error).toBeDefined() + } + + // verify the failing ids are the corrupted ones + const failingIds = result.errors.map((e) => e.id) + expect(failingIds).toContain("2") + expect(failingIds).toContain("3") + }) + .pipe( + Effect.provide(MemoryStoreLive), + setupRequestContextFromCurrent(), + Effect.runPromise + )) + + it("returns empty result for empty repository", () => + Effect + .gen(function*() { + const repo = yield* makeRepo("EmptyItem", SimpleItem, {}) + + const result = yield* repo.validateSample({ percentage: 1.0 }) + + expect(result.total).toBe(0) + expect(result.sampled).toBe(0) + expect(result.valid).toBe(0) + expect(result.errors).toHaveLength(0) + }) + .pipe( + Effect.provide(MemoryStoreLive), + setupRequestContextFromCurrent(), + Effect.runPromise + )) + + it("respects maxItems option", () => + Effect + .gen(function*() { + const repo = yield* makeRepo("MaxItemsTest", SimpleItem, { + makeInitial: Effect.succeed([ + new SimpleItem({ id: "1", name: S.NonEmptyString255("A"), count: S.NonNegativeInt(1) }), + new SimpleItem({ id: "2", name: S.NonEmptyString255("B"), count: S.NonNegativeInt(2) }), + new SimpleItem({ id: "3", name: S.NonEmptyString255("C"), count: S.NonNegativeInt(3) }), + new SimpleItem({ id: "4", name: S.NonEmptyString255("D"), count: S.NonNegativeInt(4) }), + new SimpleItem({ id: "5", name: S.NonEmptyString255("E"), count: S.NonNegativeInt(5) }) + ]) + }) + + const result = yield* repo.validateSample({ + percentage: 1.0, // 100% + maxItems: 2 // but cap at 2 + }) + + expect(result.total).toBe(5) + expect(result.sampled).toBe(2) + expect(result.valid).toBe(2) + expect(result.errors).toHaveLength(0) + }) + .pipe( + Effect.provide(MemoryStoreLive), + setupRequestContextFromCurrent(), + Effect.runPromise + )) + + it("validates with jitM transformation that adds defaults", () => + Effect + .gen(function*() { + // schema that expects a 'status' field + class ItemWithStatus extends S.Class("ItemWithStatus")({ + id: S.String, + status: S.Literal("active", "inactive") + }) {} + + // jitM that adds default status for items + const repo = yield* makeRepo("ItemWithStatus", ItemWithStatus, { + jitM: (pm) => ({ + ...pm, + status: pm.status ?? "active" // default to active if missing + }), + makeInitial: Effect.succeed([ + new ItemWithStatus({ id: "1", status: "active" }), + new ItemWithStatus({ id: "2", status: "inactive" }) + ]) + }) + + const result = yield* repo.validateSample({ percentage: 1.0 }) + + expect(result.total).toBe(2) + expect(result.sampled).toBe(2) + expect(result.valid).toBe(2) + expect(result.errors).toHaveLength(0) + }) + .pipe( + Effect.provide(MemoryStoreLive), + setupRequestContextFromCurrent(), + Effect.runPromise + )) + + it("captures full context in validation errors", () => + Effect + .gen(function*() { + // jitM that corrupts the data + const corruptingJitM = (pm: typeof SimpleItem.Encoded) => ({ + ...pm, + count: -999 // always corrupt count + }) + + const repo = yield* makeRepo("ContextErrorTest", SimpleItem, { + jitM: corruptingJitM, + makeInitial: Effect.succeed([ + new SimpleItem({ id: "bad-item", name: S.NonEmptyString255("Test"), count: S.NonNegativeInt(100) }) + ]) + }) + + const result = yield* repo.validateSample({ percentage: 1.0 }) + + expect(result.errors).toHaveLength(1) + + const error = result.errors[0]! + expect(error.id).toBe("bad-item") + + // rawData should contain the original db data (with valid count) + expect(error.rawData).toMatchObject({ + id: "bad-item", + name: "Test", + count: 100 + }) + + // jitMResult should contain the corrupted data + expect(error.jitMResult).toMatchObject({ + id: "bad-item", + name: "Test", + count: -999 + }) + + // error should be a ParseError + expect(error.error).toBeDefined() + expect((error.error as any)._tag).toBe("ParseError") + }) + .pipe( + Effect.provide(MemoryStoreLive), + setupRequestContextFromCurrent(), + Effect.runPromise + )) + + it("handles single item validation", () => + Effect + .gen(function*() { + const repo = yield* makeRepo("SingleItem", SimpleItem, { + makeInitial: Effect.succeed([ + new SimpleItem({ id: "only", name: S.NonEmptyString255("OnlyOne"), count: S.NonNegativeInt(42) }) + ]) + }) + + const result = yield* repo.validateSample({ percentage: 1.0 }) + + expect(result.total).toBe(1) + expect(result.sampled).toBe(1) + expect(result.valid).toBe(1) + expect(result.errors).toHaveLength(0) + }) + .pipe( + Effect.provide(MemoryStoreLive), + setupRequestContextFromCurrent(), + Effect.runPromise + )) +}) From 0627530c34812c56564ceec9e4376984f4534747 Mon Sep 17 00:00:00 2001 From: jfet97 Date: Mon, 8 Dec 2025 10:40:34 +0100 Subject: [PATCH 2/2] cs --- .changeset/shiny-moose-obey.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/shiny-moose-obey.md diff --git a/.changeset/shiny-moose-obey.md b/.changeset/shiny-moose-obey.md new file mode 100644 index 000000000..756df5edd --- /dev/null +++ b/.changeset/shiny-moose-obey.md @@ -0,0 +1,5 @@ +--- +"@effect-app/infra": minor +--- + +add validateSample method to Repository for schema validation testing