@effect-app/infra 4.0.0-beta.327 → 4.0.0-beta.329

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  import type { FieldValues } from "effect-app/Model/filter/types"
2
+ import * as Option from "effect-app/Option"
2
3
  import * as S from "effect-app/Schema"
3
4
  import type { PersistenceModelType, StoreConfig } from "effect-app/Store"
5
+ import * as Result from "effect/Result"
4
6
  import { toJsonQueryValue } from "./utils.ts"
5
7
 
6
8
  export interface JsonDocumentCodec<E extends FieldValues> {
@@ -8,6 +10,18 @@ export interface JsonDocumentCodec<E extends FieldValues> {
8
10
  readonly decode: (doc: PersistenceModelType<E>) => PersistenceModelType<E>
9
11
  }
10
12
 
13
+ /**
14
+ * The store *read* boundary decode: a stored JSON document in, either the
15
+ * Encoded document or the `S.SchemaError` saying why it is not one.
16
+ *
17
+ * Synchronous on purpose: it runs once per document over whole result sets, so
18
+ * it must not allocate an Effect per row. Adapters lift the `Result` into their
19
+ * Effect channel once per operation (see {@link decodeStoredMany}).
20
+ */
21
+ export type StoredDecode<E extends FieldValues> = (
22
+ doc: PersistenceModelType<E>
23
+ ) => Result.Result<PersistenceModelType<E>, S.SchemaError>
24
+
11
25
  const splitEtag = <E extends FieldValues>(doc: PersistenceModelType<E>) => {
12
26
  const { _etag, ...rest } = doc
13
27
  return { rest: rest as E, _etag }
@@ -18,6 +32,9 @@ const joinEtag = <E extends FieldValues>(
18
32
  _etag: string | undefined
19
33
  ): PersistenceModelType<E> => (_etag === undefined ? rest : { ...rest, _etag })
20
34
 
35
+ /** JSON document ↔ Encoded document codec for a store's domain schema. */
36
+ const jsonCodec = <E extends FieldValues>(schema: S.Top) => S.toCodecJson(S.toEncoded(schema)) as S.Codec<E, S.Json>
37
+
21
38
  /**
22
39
  * Encoded document ↔ JSON document. Prefer `Schema.toCodecJson(toEncoded(schema))`
23
40
  * when the store has a schema; otherwise lower Date/Map/Set structurally.
@@ -26,18 +43,17 @@ const joinEtag = <E extends FieldValues>(
26
43
  * document that does not match the Encoded shape fails loudly rather than being
27
44
  * read back half-decoded.
28
45
  *
29
- * Migrating a legacy-shaped document is therefore not this codec's job: the
30
- * store applies `StoreConfig.jitM` to the raw JSON document *before* calling
31
- * `decode` (see {@link makeStoredDecode}), which is the only place where a
32
- * document can be repaired. A document `jitM` does not repair is a genuine
33
- * failure and throws here.
46
+ * This is the **write** path codec: `encode` on the way to the database, and
47
+ * `decode` to hand the just-written document back. The read path uses
48
+ * {@link makeStoredDecode}, which applies `jitM` first and reports a document it
49
+ * does not repair as a typed `S.SchemaError` instead of throwing.
34
50
  *
35
51
  * `_etag` is infrastructure metadata rather than part of the domain document,
36
52
  * so it is split off before the codec runs and re-attached afterwards.
37
53
  */
38
54
  export const makeJsonDocumentCodec = <E extends FieldValues>(schema?: S.Top): JsonDocumentCodec<E> => {
39
55
  if (schema) {
40
- const codec = S.toCodecJson(S.toEncoded(schema)) as S.Codec<E, S.Json>
56
+ const codec = jsonCodec<E>(schema)
41
57
  return {
42
58
  encode: (doc) => {
43
59
  const { rest, _etag } = splitEtag(doc)
@@ -58,31 +74,70 @@ export const makeJsonDocumentCodec = <E extends FieldValues>(schema?: S.Top): Js
58
74
  /**
59
75
  * The store *read* boundary: apply `jitM` to the raw JSON document - after the
60
76
  * adapter merged `defaultValues` into it - and only then decode JSON→Encoded
61
- * with the strict {@link makeJsonDocumentCodec} codec.
77
+ * with the strict `Schema.toCodecJson(Schema.toEncoded(schema))` codec.
62
78
  *
63
79
  * This is the one place a legacy-shaped document can be repaired, and why
64
80
  * `jitM` can fix things no schema decode could - an explicit `null` where a
65
81
  * `Date` is expected, a key added after the document was written. It runs
66
- * before any schema decode sees the document; a document it does not repair
67
- * still fails loudly in `codec.decode`.
82
+ * before any schema decode sees the document.
83
+ *
84
+ * Nothing here is lenient: a document `jitM` does not repair still fails. It
85
+ * fails as a typed `S.SchemaError` in a `Result` (via `S.decodeUnknownResult`)
86
+ * rather than by throwing, so adapters can turn it into `Effect.fail` and
87
+ * callers - `Repository.validateSample` in particular - can inspect it per
88
+ * document instead of losing the whole run to a defect.
68
89
  *
69
90
  * `_etag` is infrastructure metadata rather than part of the domain document,
70
91
  * so it is split off first and `jitM` never sees it.
71
- *
72
- * Returns `codec.decode` unchanged when there is no `jitM`; the write path,
73
- * which must not run migrations, keeps using `codec.decode` directly.
74
92
  */
75
93
  export const makeStoredDecode = <E extends FieldValues>(
76
- codec: JsonDocumentCodec<E>,
94
+ schema: StoreConfig<E>["schema"],
77
95
  jitM: StoreConfig<E>["jitM"]
78
- ): JsonDocumentCodec<E>["decode"] => {
79
- if (jitM === undefined) return codec.decode
96
+ ): StoredDecode<E> => {
97
+ if (schema === undefined) {
98
+ // schemaless store: the stored document is already the Encoded document,
99
+ // so there is nothing to decode - only `jitM` to apply.
100
+ if (jitM === undefined) return Result.succeed
101
+ return (doc) => {
102
+ const { rest, _etag } = splitEtag(doc)
103
+ return Result.succeed(joinEtag(jitM(rest) as unknown as E, _etag))
104
+ }
105
+ }
106
+ const decode = S.decodeUnknownResult(jsonCodec<E>(schema))
80
107
  return (doc) => {
81
108
  const { rest, _etag } = splitEtag(doc)
82
109
  // adapters type the stored document as the Encoded persistence model, but
83
110
  // at this point it is still the raw JSON document that `jitM` is written
84
- // against - and `jitM` returns JSON, which is what `codec.decode` consumes.
85
- const migrated = jitM(rest) as unknown as E
86
- return codec.decode(joinEtag(migrated, _etag))
111
+ // against - and `jitM` returns JSON, which is what the codec consumes.
112
+ const json = jitM === undefined ? rest : jitM(rest) as unknown as E
113
+ return Result.map(decode(json), (decoded) => joinEtag(decoded, _etag))
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Decode a whole result set, failing fast on the first bad document.
119
+ *
120
+ * One synchronous loop and one `Result` for the batch: no Effect per document,
121
+ * and no allocation beyond the decoded array (plus a `Result` wrapper) on the
122
+ * happy path. Adapters lift the returned `Result` with `Effect.fromResult`
123
+ * once, at the end.
124
+ */
125
+ export const decodeStoredMany = <A, B>(
126
+ docs: readonly A[],
127
+ decode: (doc: A) => Result.Result<B, S.SchemaError>
128
+ ): Result.Result<B[], S.SchemaError> => {
129
+ const decoded: B[] = []
130
+ for (const doc of docs) {
131
+ const result = decode(doc)
132
+ if (Result.isFailure(result)) return Result.fail(result.failure)
133
+ decoded.push(result.success)
87
134
  }
135
+ return Result.succeed(decoded)
88
136
  }
137
+
138
+ /** Decode an optional stored document; `None` short-circuits without decoding. */
139
+ export const decodeStoredOption = <A, B>(
140
+ doc: Option.Option<A>,
141
+ decode: (doc: A) => Result.Result<B, S.SchemaError>
142
+ ): Result.Result<Option.Option<B>, S.SchemaError> =>
143
+ Option.isNone(doc) ? Result.succeed(Option.none()) : Result.map(decode(doc.value), Option.some)
package/src/arbs.ts CHANGED
@@ -23,13 +23,14 @@ export function generateFromArbitrary<T>(arb: S.Arbitrary<T>) {
23
23
  return generate(arb(FastCheck))
24
24
  }
25
25
 
26
- // Advance the seed per call: deterministic across runs, but successive calls yield different
27
- // samples (like `generate` advancing its shared fast-check Random).
26
+ // Native `sampleEffect({ count: 1 })` always uses attempt 0 of the master seed (edge-biased).
27
+ // Integer `seed + n` stayed in that bucket and collided for StringId. Mix a per-call suffix
28
+ // so each call is a jump-before-toss, like successive `generate(rnd)` draws.
28
29
  let schemaSamples = 0
29
30
 
30
31
  export function generateFromSchema<S extends S.Constraint>(schema: S) {
31
32
  const samples = Effect.runSync(
32
- Arbitrary.sampleEffect(Arbitrary.schema(schema), { count: 1, seed: seed + schemaSamples++ })
33
+ Arbitrary.sampleEffect(Arbitrary.schema(schema), { count: 1, seed: `${seed}:${schemaSamples++}` })
33
34
  )
34
35
  const value = samples[0]
35
36
  if (value === undefined) {
@@ -14,8 +14,20 @@ describe("generateFromSchema number magnitudes", () => {
14
14
  }
15
15
  })
16
16
 
17
+ it("keeps Finite samples realistic", () => {
18
+ const schema = S.Struct({ amount: S.Finite, weight: S.Finite })
19
+ for (let i = 0; i < 200; i++) {
20
+ const { amount, weight } = generateFromSchema(schema).value
21
+ expect(Number.isFinite(amount)).toBe(true)
22
+ expect(Number.isFinite(weight)).toBe(true)
23
+ expect(Math.max(Math.abs(amount), Math.abs(weight))).toBeLessThanOrEqual(1_000_000)
24
+ expect(Number.isFinite(amount * weight)).toBe(true)
25
+ }
26
+ })
27
+
17
28
  it("does not change the JSON Schema of the number schemas", () => {
18
29
  expect(S.toJsonSchemaDocument(S.PositiveNumber).schema).not.toHaveProperty("maximum")
19
30
  expect(S.toJsonSchemaDocument(S.NonNegativeNumber).schema).not.toHaveProperty("maximum")
31
+ expect(S.toJsonSchemaDocument(S.Finite).schema).not.toHaveProperty("maximum")
20
32
  })
21
33
  })
package/test/arbs.test.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from "@effect/vitest"
2
+ import { RequestId } from "effect-app/ids"
2
3
  import * as S from "effect-app/Schema"
3
4
  import { generateFromSchema } from "../src/arbs.js"
4
5
 
@@ -18,4 +19,23 @@ describe("generateFromSchema", () => {
18
19
  expect(S.is(schema)(generateFromSchema(schema).value)).toBe(true)
19
20
  }
20
21
  })
22
+
23
+ it("successive StringId samples are unique (count:1 jump, not attempt-0 edge strings)", () => {
24
+ const Item = S.Struct({ id: S.StringId, amount: S.Finite })
25
+ const ids = Array.from({ length: 20 }, () => generateFromSchema(Item).value.id)
26
+ expect(new Set(ids).size).toBe(20)
27
+ })
28
+
29
+ it("successive RequestId samples are unique", () => {
30
+ const ids = Array.from({ length: 20 }, () => generateFromSchema(RequestId).value)
31
+ expect(new Set(ids).size).toBe(20)
32
+ })
33
+
34
+ it("samples Url as https URLs", () => {
35
+ for (let i = 0; i < 10; i++) {
36
+ const url = generateFromSchema(S.Url).value
37
+ expect(url.startsWith("https://")).toBe(true)
38
+ expect(S.is(S.Url)(url)).toBe(true)
39
+ }
40
+ })
21
41
  })
@@ -9,8 +9,11 @@ import * as S from "effect-app/Schema"
9
9
  import { setupRequestContextFromCurrent } from "effect-app/setupRequest"
10
10
  import type { JsonRecord } from "effect-app/Store"
11
11
  import { StoreMaker } from "effect-app/Store"
12
+ import * as Cause from "effect/Cause"
13
+ import * as Exit from "effect/Exit"
14
+ import * as Result from "effect/Result"
12
15
  import { describe, expect, it } from "vitest"
13
- import { makeJsonDocumentCodec, makeStoredDecode } from "../src/Store/jsonDocument.js"
16
+ import { decodeStoredMany, decodeStoredOption, makeStoredDecode } from "../src/Store/jsonDocument.js"
14
17
  import { makeMemoryStoreInt } from "../src/Store/Memory.js"
15
18
  import { makeJsonLower } from "../src/Store/utils.js"
16
19
 
@@ -39,12 +42,15 @@ const LegacyDocStoreLive = (docs: readonly Record<string, unknown>[]) =>
39
42
  makeJsonLower(config)
40
43
  ),
41
44
  (store) => {
42
- const decode = makeStoredDecode<any>(makeJsonDocumentCodec<any>(config?.schema), config?.jitM)
45
+ const decode = makeStoredDecode<any>(config?.schema, config?.jitM)
43
46
  return {
44
47
  ...store,
45
- all: Effect.map(store.all, (rows) => rows.map(decode)),
46
- find: (id: any) => Effect.map(store.find(id), Option.map(decode)),
47
- filter: (f: any) => Effect.map(store.filter(f), (rows: any[]) => f.select ? rows : rows.map(decode))
48
+ all: Effect.flatMap(store.all, (rows) => Effect.fromResult(decodeStoredMany(rows, decode))),
49
+ find: (id: any) =>
50
+ Effect.flatMap(store.find(id), (doc) => Effect.fromResult(decodeStoredOption(doc, decode))),
51
+ filter: (f: any) =>
52
+ Effect.flatMap(store.filter(f), (rows: any[]) =>
53
+ f.select ? Effect.succeed(rows) : Effect.fromResult(decodeStoredMany(rows, decode)))
48
54
  }
49
55
  }
50
56
  )) as any
@@ -63,6 +69,12 @@ class Shop extends S.Class<Shop>("LegacyDocShop")({
63
69
  // `vatRate` was added later; stored documents from before that do not have it.
64
70
  const jitM = (json: JsonRecord): JsonRecord => "vatRate" in json ? json : { ...json, vatRate: 19 }
65
71
 
72
+ /** does *not* repair `vatRate`, so `legacyDoc` cannot decode at the store boundary */
73
+ const jitMThatDoesNotRepair = (json: JsonRecord): JsonRecord => ({
74
+ ...json,
75
+ updatedAt: json["updatedAt"] ?? "2024-06-03T00:00:00.000Z"
76
+ })
77
+
66
78
  const legacyDoc = {
67
79
  id: "shop-legacy",
68
80
  name: "Legacy Shop",
@@ -270,53 +282,106 @@ describe("repository reads of legacy documents", () => {
270
282
  /**
271
283
  * The store boundary decode is deliberately **strict**: `jitM` is the one and
272
284
  * only place a legacy document can be repaired, and a document it does not
273
- * repair must fail loudly rather than be read back half-decoded. This test
274
- * pins that behaviour down on purpose - do not "fix" it by making the decode
275
- * lenient.
285
+ * repair must fail rather than be read back half-decoded. This test pins that
286
+ * behaviour down on purpose - do not "fix" it by making the decode lenient.
287
+ *
288
+ * It fails as a *typed* `S.SchemaError` in the store's error channel (it used
289
+ * to throw out of `decodeSync` as a defect), which is what lets
290
+ * `validateSample` report the document instead of losing the whole run.
276
291
  */
277
- it("a document jitM does not repair fails loudly at the store boundary", async () => {
278
- const program = Effect
292
+ it("a document jitM does not repair fails as a typed SchemaError at the store boundary", () =>
293
+ Effect
279
294
  .gen(function*() {
295
+ const { make } = yield* StoreMaker
280
296
  // `vatRate` is still missing after this jitM ran: it only touches `updatedAt`
281
- const repo = yield* makeRepo("LegacyDocShop", Shop, {
282
- jitM: (json) => ({ ...json, updatedAt: json["updatedAt"] ?? "2024-06-03T00:00:00.000Z" })
297
+ const store = yield* make<"id", any>("LegacyDocShop", "id", undefined, {
298
+ partitionValue: () => "primary",
299
+ schema: Shop,
300
+ jitM: jitMThatDoesNotRepair
283
301
  })
284
302
 
285
- return yield* repo.all
303
+ // `Effect.result` only catches typed failures - a defect would reject here
304
+ const result = yield* store.all.pipe(Effect.result)
305
+
306
+ expect(Result.isFailure(result)).toBe(true)
307
+ if (!Result.isFailure(result)) return
308
+ const failure = result.failure
309
+ expect(S.isSchemaError(failure)).toBe(true)
310
+ expect((failure as S.SchemaError)._tag).toBe("SchemaError")
311
+ // the strict `toCodecJson(toEncoded(schema))` decode still reports the
312
+ // key jitM failed to add, with the path to it
313
+ expect((failure as S.SchemaError).message).toContain("Missing key")
314
+ expect((failure as S.SchemaError).message).toContain("vatRate")
286
315
  })
287
316
  .pipe(
288
317
  Effect.provide(TestLive),
289
318
  setupRequestContextFromCurrent(),
290
319
  Effect.scoped,
291
320
  Effect.runPromise
292
- )
293
-
294
- let caught: unknown = undefined
295
- try {
296
- await program
297
- } catch (e) {
298
- caught = e
299
- }
300
-
301
- expect(caught).toBeDefined()
302
- const message = caught instanceof Error ? caught.message : String(caught)
303
- // the strict `toCodecJson(toEncoded(schema))` decode reports the key jitM
304
- // failed to add, with the path to it
305
- expect(message).toContain("Missing key")
306
- expect(message).toContain("vatRate")
307
- })
321
+ ))
322
+
323
+ /**
324
+ * ...and the *public* repository surface is unchanged: `Repository.find`/`all`
325
+ * declare only `DatabaseError`, so the store's `SchemaError` becomes a defect
326
+ * when the public members are produced.
327
+ */
328
+ it("the same document still dies on a public repository read", () =>
329
+ Effect
330
+ .gen(function*() {
331
+ const repo = yield* makeRepo("LegacyDocShop", Shop, { jitM: jitMThatDoesNotRepair })
332
+
333
+ const exit = yield* repo.all.pipe(Effect.exit)
334
+ expect(Exit.isFailure(exit)).toBe(true)
335
+ if (Exit.isFailure(exit)) {
336
+ expect(Cause.hasDies(exit.cause)).toBe(true)
337
+ expect(Cause.hasFails(exit.cause)).toBe(false)
338
+ }
339
+
340
+ // a defect is not catchable as a typed failure
341
+ const notCaught = yield* repo.find("shop-legacy").pipe(Effect.result, Effect.exit)
342
+ expect(Exit.isFailure(notCaught)).toBe(true)
343
+ })
344
+ .pipe(
345
+ Effect.provide(TestLive),
346
+ setupRequestContextFromCurrent(),
347
+ Effect.scoped,
348
+ Effect.runPromise
349
+ ))
350
+
351
+ it("validateSample reports a document that fails at the store boundary and keeps going", () =>
352
+ Effect
353
+ .gen(function*() {
354
+ const repo = yield* makeRepo("LegacyDocShop", Shop, { jitM: jitMThatDoesNotRepair })
355
+
356
+ const result = yield* repo.validateSample({ percentage: 1.0 })
357
+
358
+ expect(result.total).toBe(2)
359
+ expect(result.sampled).toBe(2)
360
+ // `shop-current` decodes, `shop-legacy` does not
361
+ expect(result.valid).toBe(1)
362
+ expect(result.errors).toHaveLength(1)
363
+ const error = result.errors[0]!
364
+ expect(error.id).toBe("shop-legacy")
365
+ expect(S.isSchemaError(error.error)).toBe(true)
366
+ })
367
+ .pipe(
368
+ Effect.provide(TestLive),
369
+ setupRequestContextFromCurrent(),
370
+ Effect.scoped,
371
+ Effect.runPromise
372
+ ))
308
373
 
309
374
  it("_etag is not visible to jitM and survives the round trip", () => {
310
375
  const seen: JsonRecord[] = []
311
376
  const decode = makeStoredDecode<any>(
312
- makeJsonDocumentCodec<any>(Shop),
377
+ Shop,
313
378
  (json) => {
314
379
  seen.push(json)
315
380
  return { ...json, vatRate: 19 }
316
381
  }
317
382
  )
318
383
 
319
- const decoded = decode({ ...legacyDoc, _etag: "etag-1" } as any)
384
+ const decoded = Result.getOrThrow(decode({ ...legacyDoc, _etag: "etag-1" } as any))
320
385
 
321
386
  // jitM sees the document without the infra metadata
322
387
  expect(seen).toHaveLength(1)