Skip to content

Commit 5777431

Browse files
authored
feat(infra): add validateSample method to Repository (#608)
* feat(infra): add validateSample method to Repository for schema validation testing * cs
1 parent b252b20 commit 5777431

File tree

7 files changed

+351
-1
lines changed

7 files changed

+351
-1
lines changed

.changeset/shiny-moose-obey.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@effect-app/infra": minor
3+
---
4+
5+
add validateSample method to Repository for schema validation testing

packages/infra/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@
114114
"types": "./dist/Model/Repository/service.d.ts",
115115
"default": "./dist/Model/Repository/service.js"
116116
},
117+
"./Model/Repository/validation": {
118+
"types": "./dist/Model/Repository/validation.d.ts",
119+
"default": "./dist/Model/Repository/validation.js"
120+
},
117121
"./Model/dsl": {
118122
"types": "./dist/Model/dsl.d.ts",
119123
"default": "./dist/Model/dsl.js"

packages/infra/src/Model/Repository.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from "./Repository/ext.js"
22
export * from "./Repository/legacy.js"
33
export { makeRepo } from "./Repository/makeRepo.js"
44
export * from "./Repository/service.js"
5+
export * from "./Repository/validation.js"

packages/infra/src/Model/Repository/internal/internal.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
import type {} from "effect/Equal"
33
import type {} from "effect/Hash"
4-
import { Array, Chunk, Context, Effect, Equivalence, flow, type NonEmptyReadonlyArray, Option, pipe, Pipeable, PubSub, S, Unify } from "effect-app"
4+
import { Array, Chunk, Context, Effect, Either, Equivalence, flow, type NonEmptyReadonlyArray, Option, pipe, Pipeable, PubSub, S, Unify } from "effect-app"
55
import { toNonEmptyArray } from "effect-app/Array"
66
import { NotFoundError } from "effect-app/client/errors"
77
import { flatMapOption } from "effect-app/Effect"
@@ -12,6 +12,7 @@ import { getContextMap } from "../../../Store/ContextMapContainer.js"
1212
import type { FieldValues } from "../../filter/types.js"
1313
import * as Q from "../../query.js"
1414
import type { Repository } from "../service.js"
15+
import { ValidationError, ValidationResult } from "../validation.js"
1516

1617
const dedupe = Array.dedupeWith(Equivalence.string)
1718

@@ -322,6 +323,64 @@ export function makeRepoInternal<
322323
)
323324
}) as any
324325

326+
const validateSample = Effect.fn("validateSample")(function*(options?: {
327+
percentage?: number
328+
maxItems?: number
329+
}) {
330+
const percentage = options?.percentage ?? 0.1 // default 10%
331+
const maxItems = options?.maxItems
332+
333+
// 1. get all IDs with projection (bypasses main schema decode)
334+
const allIds = yield* store.filter({
335+
t: null as unknown as Encoded,
336+
select: [idKey as keyof Encoded]
337+
})
338+
339+
// 2. random subset
340+
const shuffled = [...allIds].sort(() => Math.random() - 0.5)
341+
const sampleSize = Math.min(
342+
maxItems ?? Infinity,
343+
Math.ceil(allIds.length * percentage)
344+
)
345+
const sample = shuffled.slice(0, sampleSize)
346+
347+
// 3. validate each item
348+
const errors: ValidationError[] = []
349+
350+
for (const item of sample) {
351+
const id = item[idKey]
352+
const rawResult = yield* store.find(id)
353+
354+
if (Option.isNone(rawResult)) continue
355+
356+
const rawData = rawResult.value as Encoded
357+
const jitMResult = mapFrom(rawData) // apply jitM
358+
359+
const decodeResult = yield* S.decode(schema)(jitMResult).pipe(
360+
Effect.either,
361+
provideRctx
362+
)
363+
364+
if (Either.isLeft(decodeResult)) {
365+
errors.push(
366+
new ValidationError({
367+
id,
368+
rawData,
369+
jitMResult,
370+
error: decodeResult.left
371+
})
372+
)
373+
}
374+
}
375+
376+
return new ValidationResult({
377+
total: NonNegativeInt(allIds.length),
378+
sampled: NonNegativeInt(sample.length),
379+
valid: NonNegativeInt(sample.length - errors.length),
380+
errors
381+
})
382+
})
383+
325384
const r: Repository<T, Encoded, Evt, ItemType, IdKey, Exclude<R, RCtx>, RPublish> = {
326385
changeFeed,
327386
itemType: name,
@@ -331,6 +390,7 @@ export function makeRepoInternal<
331390
saveAndPublish,
332391
removeAndPublish,
333392
removeById,
393+
validateSample,
334394
queryRaw(schema, q) {
335395
const dec = S.decode(S.Array(schema))
336396
return store.queryRaw(q).pipe(Effect.flatMap(dec))

packages/infra/src/Model/Repository/service.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { NonNegativeInt } from "effect-app/Schema/numbers"
55
import type { FieldValues, IsNever, ResolveFirstLevel } from "../filter/types.js"
66
import type { QAll, Query, QueryProjection, RawQuery } from "../query.js"
77
import type { Mapped } from "./legacy.js"
8+
import type { ValidationResult } from "./validation.js"
89

910
export interface Repository<
1011
T,
@@ -535,6 +536,17 @@ export interface Repository<
535536

536537
/** @deprecated use query */
537538
readonly mapped: Mapped<Encoded>
539+
540+
/**
541+
* Validates a random sample of repository items by applying jitM and schema decode.
542+
* Useful for testing that migrations and schema changes work correctly on existing data.
543+
*/
544+
readonly validateSample: (options?: {
545+
/** percentage of items to sample (0.0-1.0), default 0.1 (10%) */
546+
percentage?: number
547+
/** optional maximum number of items to sample */
548+
maxItems?: number
549+
}) => Effect.Effect<ValidationResult, never, RSchema>
538550
}
539551

540552
type DistributeQueryIfExclusiveOnArray<Exclusive extends boolean, T, EncodedRefined> = [Exclusive] extends [true]
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { S } from "effect-app"
2+
import { NonNegativeInt } from "effect-app/Schema"
3+
4+
/**
5+
* Represents a single validation error when decoding a repository item.
6+
* Contains full context for debugging: raw data, jitM result, and decode error.
7+
*/
8+
export class ValidationError extends S.Class<ValidationError>("@effect-app/infra/ValidationError")({
9+
/** the id of the item that failed validation */
10+
id: S.Unknown,
11+
/** the raw data from the database before jitM */
12+
rawData: S.Unknown,
13+
/** the data after applying jitM transformation */
14+
jitMResult: S.Unknown,
15+
/** the ParseResult.ParseError from schema decode */
16+
error: S.Unknown
17+
}) {}
18+
19+
/**
20+
* Result of validating a sample of repository items.
21+
*/
22+
export class ValidationResult extends S.Class<ValidationResult>("@effect-app/infra/ValidationResult")({
23+
/** total number of items in the repository */
24+
total: NonNegativeInt,
25+
/** number of items that were sampled for validation */
26+
sampled: NonNegativeInt,
27+
/** number of items that passed validation */
28+
valid: NonNegativeInt,
29+
/** list of validation errors with full context */
30+
errors: S.Array(ValidationError)
31+
}) {}

0 commit comments

Comments
 (0)