Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/shiny-moose-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect-app/infra": minor
---

add validateSample method to Repository for schema validation testing
4 changes: 4 additions & 0 deletions packages/infra/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions packages/infra/src/Model/Repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
62 changes: 61 additions & 1 deletion packages/infra/src/Model/Repository/internal/internal.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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)

Expand Down Expand Up @@ -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<T, Encoded, Evt, ItemType, IdKey, Exclude<R, RCtx>, RPublish> = {
changeFeed,
itemType: name,
Expand All @@ -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))
Expand Down
12 changes: 12 additions & 0 deletions packages/infra/src/Model/Repository/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -535,6 +536,17 @@ export interface Repository<

/** @deprecated use query */
readonly mapped: Mapped<Encoded>

/**
* 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<ValidationResult, never, RSchema>
}

type DistributeQueryIfExclusiveOnArray<Exclusive extends boolean, T, EncodedRefined> = [Exclusive] extends [true]
Expand Down
31 changes: 31 additions & 0 deletions packages/infra/src/Model/Repository/validation.ts
Original file line number Diff line number Diff line change
@@ -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<ValidationError>("@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<ValidationResult>("@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)
}) {}
Loading