@effect-app/infra 4.0.0-beta.324 → 4.0.0-beta.326

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.
@@ -18,7 +18,7 @@ import * as Struct from "effect/Struct"
18
18
  import { InfraLogger } from "../logger.ts"
19
19
  import { annotateDb } from "../otel.ts"
20
20
  import { codeFilter, codeFilter3_ } from "./codeFilter.ts"
21
- import { makeJsonDocumentCodec } from "./jsonDocument.ts"
21
+ import { makeJsonDocumentCodec, makeStoredDecode } from "./jsonDocument.ts"
22
22
  import { get, jsonifyFilter, type JsonLower, makeJsonLower, makeUpdateETag, toJsonQueryValue } from "./utils.ts"
23
23
 
24
24
  export { get } from "./utils.ts"
@@ -335,14 +335,16 @@ export function makeMemoryStoreInt<IdKey extends keyof Encoded, Encoded extends
335
335
  seed?: Effect.Effect<Iterable<Encoded>, E, R>,
336
336
  _defaultValues?: Partial<Encoded>,
337
337
  schema?: StoreConfig<Encoded>["schema"],
338
- json?: JsonLower
338
+ json?: JsonLower,
339
+ jitM?: StoreConfig<Encoded>["jitM"]
339
340
  ) {
340
341
  type PM = PersistenceModelType<Encoded>
341
342
  return Effect.gen(function*() {
342
343
  const updateETag = makeUpdateETag(modelName)
343
344
  const codec = makeJsonDocumentCodec<Encoded>(schema)
344
345
  const encodeDoc = (e: Encoded | PM): PM => codec.encode({ _etag: undefined, ...e })
345
- const decodeDoc = (e: PM): PM => codec.decode(e)
346
+ // read path: stored JSON -> jitM -> JSON→Encoded decode
347
+ const decodeDoc = makeStoredDecode<Encoded>(codec, jitM)
346
348
  const items_ = yield* seed ?? Effect.sync(() => [])
347
349
  const toJson = json?.toJson ?? toJsonQueryValue
348
350
  const lowerFilter = json?.jsonifyFilter ?? jsonifyFilter
@@ -546,7 +548,8 @@ export const makeMemoryStore = () => ({
546
548
  seed,
547
549
  config?.defaultValues,
548
550
  config?.schema,
549
- json
551
+ json,
552
+ config?.jitM
550
553
  )
551
554
  const ctx = yield* Effect.context<R>()
552
555
  const stores = new Map([["primary", primary]])
@@ -566,7 +569,16 @@ export const makeMemoryStore = () => ({
566
569
  if (config?.allowNamespace && !config.allowNamespace(namespace)) {
567
570
  throw new Error(`Namespace ${namespace} not allowed!`)
568
571
  }
569
- return makeMemoryStoreInt(modelName, idKey, namespace, seed, config?.defaultValues, config?.schema, json)
572
+ return makeMemoryStoreInt(
573
+ modelName,
574
+ idKey,
575
+ namespace,
576
+ seed,
577
+ config?.defaultValues,
578
+ config?.schema,
579
+ json,
580
+ config?.jitM
581
+ )
570
582
  .pipe(
571
583
  Effect.orDie,
572
584
  Effect.provide(ctx),
@@ -12,7 +12,7 @@ import { SqlClient } from "effect/unstable/sql"
12
12
  import { DatabaseError, OptimisticConcurrencyException } from "../../errors.ts"
13
13
  import { InfraLogger } from "../../logger.ts"
14
14
  import { annotateDb } from "../../otel.ts"
15
- import { makeJsonDocumentCodec } from "../jsonDocument.ts"
15
+ import { makeJsonDocumentCodec, makeStoredDecode } from "../jsonDocument.ts"
16
16
  import { makeETag, makeJsonLower, toJsonQueryValue } from "../utils.ts"
17
17
  import { buildWhereSQLQuery, logQuery, pgDialect } from "./query.ts"
18
18
 
@@ -78,6 +78,8 @@ const makePgStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
78
78
  const json = makeJsonLower(config)
79
79
  const defaultValues = json.toJson(config?.defaultValues ?? {}) as Partial<Encoded>
80
80
  const codec = makeJsonDocumentCodec<Encoded>(config?.schema)
81
+ // read path: stored JSON -> defaultValues -> jitM -> JSON→Encoded decode
82
+ const decodeStored = makeStoredDecode<Encoded>(codec, config?.jitM)
81
83
 
82
84
  const resolveNamespace = !config?.allowNamespace
83
85
  ? Effect.succeed("primary")
@@ -201,7 +203,7 @@ const makePgStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
201
203
  return exec(sqlText, [ns])
202
204
  .pipe(
203
205
  Effect.map((rows) =>
204
- (rows as any[]).map((r) => parseRow<Encoded>(r, idKey, defaultValues, codec.decode))
206
+ (rows as any[]).map((r) => parseRow<Encoded>(r, idKey, defaultValues, decodeStored))
205
207
  ),
206
208
  annotateDb({
207
209
  operation: "all",
@@ -224,7 +226,7 @@ const makePgStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
224
226
  Effect.map((rows) => {
225
227
  const row = (rows as any[])[0]
226
228
  return row
227
- ? Option.some(parseRow<Encoded>(row, idKey, defaultValues, codec.decode))
229
+ ? Option.some(parseRow<Encoded>(row, idKey, defaultValues, decodeStored))
228
230
  : Option.none()
229
231
  }),
230
232
  annotateDb({
@@ -298,7 +300,7 @@ const makePgStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
298
300
  })
299
301
  }
300
302
  return (rows as any[]).map((r) =>
301
- parseRow<Encoded>(r, idKey, defaultValues, codec.decode) as any as M
303
+ parseRow<Encoded>(r, idKey, defaultValues, decodeStored) as any as M
302
304
  )
303
305
  })
304
306
  )
package/src/Store/SQL.ts CHANGED
@@ -15,7 +15,7 @@ import { SqlClient } from "effect/unstable/sql"
15
15
  import { DatabaseError, OptimisticConcurrencyException } from "../errors.ts"
16
16
  import { InfraLogger } from "../logger.ts"
17
17
  import { annotateDb, type DbSystem } from "../otel.ts"
18
- import { makeJsonDocumentCodec } from "./jsonDocument.ts"
18
+ import { makeJsonDocumentCodec, makeStoredDecode } from "./jsonDocument.ts"
19
19
  import { buildWhereSQLQuery, logQuery, type SQLDialect, sqliteDialect } from "./SQL/query.ts"
20
20
  import { makeETag, makeJsonLower, toJsonQueryValue } from "./utils.ts"
21
21
 
@@ -94,6 +94,8 @@ function makeSQLStoreInt(system: DbSystem, dialect: SQLDialect, jsonColumnType:
94
94
  const json = makeJsonLower(config)
95
95
  const defaultValues = json.toJson(config?.defaultValues ?? {}) as Partial<Encoded>
96
96
  const codec = makeJsonDocumentCodec<Encoded>(config?.schema)
97
+ // read path: stored JSON -> defaultValues -> jitM -> JSON→Encoded decode
98
+ const decodeStored = makeStoredDecode<Encoded>(codec, config?.jitM)
97
99
 
98
100
  const resolveNamespace = !config?.allowNamespace
99
101
  ? Effect.succeed("primary")
@@ -217,7 +219,7 @@ function makeSQLStoreInt(system: DbSystem, dialect: SQLDialect, jsonColumnType:
217
219
  return exec(sqlText, [ns])
218
220
  .pipe(
219
221
  Effect.map((rows) =>
220
- (rows as any[]).map((r) => parseRow<Encoded>(r, idKey, defaultValues, codec.decode))
222
+ (rows as any[]).map((r) => parseRow<Encoded>(r, idKey, defaultValues, decodeStored))
221
223
  ),
222
224
  annotateDb({
223
225
  operation: "all",
@@ -240,7 +242,7 @@ function makeSQLStoreInt(system: DbSystem, dialect: SQLDialect, jsonColumnType:
240
242
  Effect.map((rows) => {
241
243
  const row = (rows as any[])[0]
242
244
  return row
243
- ? Option.some(parseRow<Encoded>(row, idKey, defaultValues, codec.decode))
245
+ ? Option.some(parseRow<Encoded>(row, idKey, defaultValues, decodeStored))
244
246
  : Option.none()
245
247
  }),
246
248
  annotateDb({
@@ -314,7 +316,7 @@ function makeSQLStoreInt(system: DbSystem, dialect: SQLDialect, jsonColumnType:
314
316
  })
315
317
  }
316
318
  return (rows as any[]).map((r) =>
317
- parseRow<Encoded>(r, idKey, defaultValues, codec.decode) as any as M
319
+ parseRow<Encoded>(r, idKey, defaultValues, decodeStored) as any as M
318
320
  )
319
321
  })
320
322
  )
@@ -433,6 +435,8 @@ function makeSQLiteStorePerNs(
433
435
  const json = makeJsonLower(config)
434
436
  const defaultValues = json.toJson(config?.defaultValues ?? {}) as Partial<Encoded>
435
437
  const codec = makeJsonDocumentCodec<Encoded>(config?.schema)
438
+ // read path: stored JSON -> defaultValues -> jitM -> JSON→Encoded decode
439
+ const decodeStored = makeStoredDecode<Encoded>(codec, config?.jitM)
436
440
 
437
441
  const resolveNamespace = !config?.allowNamespace
438
442
  ? Effect.succeed("primary")
@@ -564,7 +568,7 @@ function makeSQLiteStorePerNs(
564
568
  return exec(ns, sqlText)
565
569
  .pipe(
566
570
  Effect.map((rows) =>
567
- (rows as any[]).map((r) => parseRow<Encoded>(r, idKey, defaultValues, codec.decode))
571
+ (rows as any[]).map((r) => parseRow<Encoded>(r, idKey, defaultValues, decodeStored))
568
572
  ),
569
573
  annotateDb({
570
574
  operation: "all",
@@ -586,7 +590,7 @@ function makeSQLiteStorePerNs(
586
590
  Effect.map((rows) => {
587
591
  const row = (rows as any[])[0]
588
592
  return row
589
- ? Option.some(parseRow<Encoded>(row, idKey, defaultValues, codec.decode))
593
+ ? Option.some(parseRow<Encoded>(row, idKey, defaultValues, decodeStored))
590
594
  : Option.none()
591
595
  }),
592
596
  annotateDb({
@@ -660,7 +664,7 @@ function makeSQLiteStorePerNs(
660
664
  })
661
665
  }
662
666
  return (rows as any[]).map((r) =>
663
- parseRow<Encoded>(r, idKey, defaultValues, codec.decode) as any as M
667
+ parseRow<Encoded>(r, idKey, defaultValues, decodeStored) as any as M
664
668
  )
665
669
  })
666
670
  )
@@ -1,6 +1,6 @@
1
1
  import type { FieldValues } from "effect-app/Model/filter/types"
2
2
  import * as S from "effect-app/Schema"
3
- import type { PersistenceModelType } from "effect-app/Store"
3
+ import type { PersistenceModelType, StoreConfig } from "effect-app/Store"
4
4
  import { toJsonQueryValue } from "./utils.ts"
5
5
 
6
6
  export interface JsonDocumentCodec<E extends FieldValues> {
@@ -21,6 +21,19 @@ const joinEtag = <E extends FieldValues>(
21
21
  /**
22
22
  * Encoded document ↔ JSON document. Prefer `Schema.toCodecJson(toEncoded(schema))`
23
23
  * when the store has a schema; otherwise lower Date/Map/Set structurally.
24
+ *
25
+ * Both directions are **strict**: they run the whole-document codec, so a
26
+ * document that does not match the Encoded shape fails loudly rather than being
27
+ * read back half-decoded.
28
+ *
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.
34
+ *
35
+ * `_etag` is infrastructure metadata rather than part of the domain document,
36
+ * so it is split off before the codec runs and re-attached afterwards.
24
37
  */
25
38
  export const makeJsonDocumentCodec = <E extends FieldValues>(schema?: S.Top): JsonDocumentCodec<E> => {
26
39
  if (schema) {
@@ -41,3 +54,35 @@ export const makeJsonDocumentCodec = <E extends FieldValues>(schema?: S.Top): Js
41
54
  decode: (doc) => doc
42
55
  }
43
56
  }
57
+
58
+ /**
59
+ * The store *read* boundary: apply `jitM` to the raw JSON document - after the
60
+ * adapter merged `defaultValues` into it - and only then decode JSON→Encoded
61
+ * with the strict {@link makeJsonDocumentCodec} codec.
62
+ *
63
+ * This is the one place a legacy-shaped document can be repaired, and why
64
+ * `jitM` can fix things no schema decode could - an explicit `null` where a
65
+ * `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`.
68
+ *
69
+ * `_etag` is infrastructure metadata rather than part of the domain document,
70
+ * 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
+ */
75
+ export const makeStoredDecode = <E extends FieldValues>(
76
+ codec: JsonDocumentCodec<E>,
77
+ jitM: StoreConfig<E>["jitM"]
78
+ ): JsonDocumentCodec<E>["decode"] => {
79
+ if (jitM === undefined) return codec.decode
80
+ return (doc) => {
81
+ const { rest, _etag } = splitEtag(doc)
82
+ // adapters type the stored document as the Encoded persistence model, but
83
+ // 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))
87
+ }
88
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import * as S from "effect-app/Schema"
3
+ import { generateFromSchema } from "../src/arbs.js"
4
+
5
+ describe("generateFromSchema number magnitudes", () => {
6
+ it("keeps PositiveNumber and NonNegativeNumber samples realistic", () => {
7
+ const schema = S.Struct({ amount: S.PositiveNumber, weight: S.NonNegativeNumber })
8
+ for (let i = 0; i < 200; i++) {
9
+ const { amount, weight } = generateFromSchema(schema).value
10
+ expect(amount).toBeGreaterThan(0)
11
+ expect(weight).toBeGreaterThanOrEqual(0)
12
+ expect(Math.max(amount, weight)).toBeLessThanOrEqual(1_000_000)
13
+ expect(Number.isFinite(amount * weight)).toBe(true)
14
+ }
15
+ })
16
+
17
+ it("does not change the JSON Schema of the number schemas", () => {
18
+ expect(S.toJsonSchemaDocument(S.PositiveNumber).schema).not.toHaveProperty("maximum")
19
+ expect(S.toJsonSchemaDocument(S.NonNegativeNumber).schema).not.toHaveProperty("maximum")
20
+ })
21
+ })
@@ -0,0 +1 @@
1
+ {"version":3,"file":"arbs-magnitude.test.d.ts","sourceRoot":"","sources":["../arbs-magnitude.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1 @@
1
+ {"version":3,"file":"repository-legacy-document.test.d.ts","sourceRoot":"","sources":["../repository-legacy-document.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,329 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+
3
+ import * as Effect from "effect-app/Effect"
4
+ import * as Layer from "effect-app/Layer"
5
+ import { makeRepo } from "effect-app/Model/Repository"
6
+ import { RepositoryRegistryLive } from "effect-app/Model/Repository/Registry"
7
+ import * as Option from "effect-app/Option"
8
+ import * as S from "effect-app/Schema"
9
+ import { setupRequestContextFromCurrent } from "effect-app/setupRequest"
10
+ import type { JsonRecord } from "effect-app/Store"
11
+ import { StoreMaker } from "effect-app/Store"
12
+ import { describe, expect, it } from "vitest"
13
+ import { makeJsonDocumentCodec, makeStoredDecode } from "../src/Store/jsonDocument.js"
14
+ import { makeMemoryStoreInt } from "../src/Store/Memory.js"
15
+ import { makeJsonLower } from "../src/Store/utils.js"
16
+
17
+ /**
18
+ * A Memory store seeded with *raw stored documents* instead of items encoded
19
+ * through the schema, so that a legacy-shaped document can reach the read path -
20
+ * exactly what Cosmos (`fromStored`) and SQL (`parseRow`) do in production.
21
+ *
22
+ * `MemoryStoreLive` cannot be used for this: it strictly `encode`s whatever it
23
+ * is seeded with, so a legacy document never makes it into the store. The inner
24
+ * store here is therefore schemaless (it may not validate what we seed), and
25
+ * the real store read boundary - `defaultValues` merged, then `jitM`, then the
26
+ * strict JSON→Encoded decode - is applied on top via {@link makeStoredDecode}.
27
+ */
28
+ const LegacyDocStoreLive = (docs: readonly Record<string, unknown>[]) =>
29
+ StoreMaker.toLayer(Effect.sync(() => ({
30
+ make: ((modelName: string, idKey: any, _seed: any, config: any) =>
31
+ Effect.map(
32
+ makeMemoryStoreInt(
33
+ modelName,
34
+ idKey,
35
+ "primary",
36
+ Effect.succeed(docs as any),
37
+ config?.defaultValues,
38
+ undefined,
39
+ makeJsonLower(config)
40
+ ),
41
+ (store) => {
42
+ const decode = makeStoredDecode<any>(makeJsonDocumentCodec<any>(config?.schema), config?.jitM)
43
+ return {
44
+ ...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
+ }
49
+ }
50
+ )) as any
51
+ })))
52
+
53
+ class Shop extends S.Class<Shop>("LegacyDocShop")({
54
+ id: S.String,
55
+ name: S.NonEmptyString255,
56
+ createdAt: S.Date,
57
+ updatedAt: S.Date,
58
+ vatRate: S.Number,
59
+ tags: S.ReadonlySet(S.String),
60
+ meta: S.ReadonlyMap({ key: S.String, value: S.String })
61
+ }) {}
62
+
63
+ // `vatRate` was added later; stored documents from before that do not have it.
64
+ const jitM = (json: JsonRecord): JsonRecord => "vatRate" in json ? json : { ...json, vatRate: 19 }
65
+
66
+ const legacyDoc = {
67
+ id: "shop-legacy",
68
+ name: "Legacy Shop",
69
+ createdAt: "2024-06-01T00:00:00.000Z",
70
+ updatedAt: "2024-06-03T00:00:00.000Z",
71
+ tags: ["a"],
72
+ meta: [["k", "v"]]
73
+ }
74
+
75
+ const currentDoc = {
76
+ id: "shop-current",
77
+ name: "Current Shop",
78
+ createdAt: "2024-06-02T00:00:00.000Z",
79
+ updatedAt: "2024-06-04T00:00:00.000Z",
80
+ vatRate: 7,
81
+ tags: ["b"],
82
+ meta: [["k2", "v2"]]
83
+ }
84
+
85
+ /** a legacy document that stored an explicit `null` where a Date is expected */
86
+ const nullDoc = { ...legacyDoc, id: "shop-null", vatRate: 21, updatedAt: null }
87
+
88
+ /** `vatRate` present but falsy, `updatedAt` an explicit null */
89
+ const zeroDoc = { ...legacyDoc, id: "shop-zero", vatRate: 0, updatedAt: null }
90
+
91
+ const TestLive = Layer.merge(LegacyDocStoreLive([legacyDoc, currentDoc]), RepositoryRegistryLive)
92
+ const NullDocLive = Layer.merge(LegacyDocStoreLive([nullDoc]), RepositoryRegistryLive)
93
+ const CurrentDocLive = Layer.merge(LegacyDocStoreLive([currentDoc]), RepositoryRegistryLive)
94
+ const DefaultsLive = Layer.merge(LegacyDocStoreLive([legacyDoc, zeroDoc]), RepositoryRegistryLive)
95
+
96
+ describe("repository reads of legacy documents", () => {
97
+ it("all: jitM fills the key the stored document is missing", () =>
98
+ Effect
99
+ .gen(function*() {
100
+ const repo = yield* makeRepo("LegacyDocShop", Shop, { jitM })
101
+
102
+ const items = yield* repo.all
103
+
104
+ expect(items).toHaveLength(2)
105
+ const legacy = items.find((_) => _.id === "shop-legacy")!
106
+ expect(legacy).toBeInstanceOf(Shop)
107
+ expect(legacy.vatRate).toBe(19)
108
+ expect(legacy.createdAt).toBeInstanceOf(Date)
109
+ expect(legacy.tags).toBeInstanceOf(Set)
110
+ expect([...legacy.tags]).toEqual(["a"])
111
+ expect(items.find((_) => _.id === "shop-current")!.vatRate).toBe(7)
112
+ })
113
+ .pipe(
114
+ Effect.provide(TestLive),
115
+ setupRequestContextFromCurrent(),
116
+ Effect.scoped,
117
+ Effect.runPromise
118
+ ))
119
+
120
+ it("find: a legacy document decodes through jitM", () =>
121
+ Effect
122
+ .gen(function*() {
123
+ const repo = yield* makeRepo("LegacyDocShop", Shop, { jitM })
124
+
125
+ const found = yield* repo.find("shop-legacy")
126
+
127
+ expect(Option.isSome(found)).toBe(true)
128
+ const shop = Option.getOrThrow(found)
129
+ expect(shop.vatRate).toBe(19)
130
+ expect(shop.name).toBe("Legacy Shop")
131
+ expect(shop.createdAt).toBeInstanceOf(Date)
132
+ })
133
+ .pipe(
134
+ Effect.provide(TestLive),
135
+ setupRequestContextFromCurrent(),
136
+ Effect.scoped,
137
+ Effect.runPromise
138
+ ))
139
+
140
+ it("validateSample: legacy documents validate because jitM runs at the store boundary", () =>
141
+ Effect
142
+ .gen(function*() {
143
+ const repo = yield* makeRepo("LegacyDocShop", Shop, { jitM })
144
+
145
+ const result = yield* repo.validateSample({ percentage: 1.0 })
146
+
147
+ expect(result.total).toBe(2)
148
+ expect(result.sampled).toBe(2)
149
+ expect(result.errors).toHaveLength(0)
150
+ expect(result.valid).toBe(2)
151
+ })
152
+ .pipe(
153
+ Effect.provide(TestLive),
154
+ setupRequestContextFromCurrent(),
155
+ Effect.scoped,
156
+ Effect.runPromise
157
+ ))
158
+
159
+ it("jitM repairs an explicit null, and the document decodes end-to-end", () =>
160
+ Effect
161
+ .gen(function*() {
162
+ // the case the store boundary exists for: `null` is not a valid
163
+ // `S.Date` encoding, so no schema decode could ever fix it
164
+ const repairNull = (json: JsonRecord): JsonRecord =>
165
+ json["updatedAt"] === null ? { ...json, updatedAt: "2024-06-05T00:00:00.000Z" } : json
166
+
167
+ const repo = yield* makeRepo("LegacyDocShop", Shop, { jitM: repairNull })
168
+
169
+ const shop = Option.getOrThrow(yield* repo.find("shop-null"))
170
+ expect(shop.updatedAt).toBeInstanceOf(Date)
171
+ expect(shop.updatedAt.toISOString()).toBe("2024-06-05T00:00:00.000Z")
172
+
173
+ const all = yield* repo.all
174
+ expect(all).toHaveLength(1)
175
+ expect(all[0]!.updatedAt.toISOString()).toBe("2024-06-05T00:00:00.000Z")
176
+ })
177
+ .pipe(
178
+ Effect.provide(NullDocLive),
179
+ setupRequestContextFromCurrent(),
180
+ Effect.scoped,
181
+ Effect.runPromise
182
+ ))
183
+
184
+ it("jitM receives the raw JSON document, and its JSON result is what the decode consumes", () =>
185
+ Effect
186
+ .gen(function*() {
187
+ const seen: JsonRecord[] = []
188
+ const observe = (json: JsonRecord): JsonRecord => {
189
+ seen.push(json)
190
+ // returning JSON - an ISO string, not a `Date` - is what decodes
191
+ return { ...json, updatedAt: "2024-06-09T00:00:00.000Z" }
192
+ }
193
+
194
+ const repo = yield* makeRepo("LegacyDocShop", Shop, { jitM: observe })
195
+
196
+ const shop = Option.getOrThrow(yield* repo.find("shop-current"))
197
+
198
+ expect(seen).toHaveLength(1)
199
+ const raw = seen[0]!
200
+ // a `Date` field arrives as an ISO string
201
+ expect(typeof raw["createdAt"]).toBe("string")
202
+ expect(raw["createdAt"]).toBe("2024-06-02T00:00:00.000Z")
203
+ // a `ReadonlySet` as an array, a `ReadonlyMap` as an array of pairs
204
+ expect(raw["tags"]).toEqual(["b"])
205
+ expect(raw["meta"]).toEqual([["k2", "v2"]])
206
+ // never native Encoded values
207
+ expect(raw["createdAt"]).not.toBeInstanceOf(Date)
208
+ expect(raw["meta"]).not.toBeInstanceOf(Map)
209
+ // `_etag` is infra metadata and is not part of the document
210
+ expect("_etag" in raw).toBe(false)
211
+
212
+ // the JSON the jitM returned was decoded to the native Encoded value
213
+ expect(shop.updatedAt).toBeInstanceOf(Date)
214
+ expect(shop.updatedAt.toISOString()).toBe("2024-06-09T00:00:00.000Z")
215
+ expect(shop.meta).toBeInstanceOf(Map)
216
+ expect([...shop.meta]).toEqual([["k2", "v2"]])
217
+ })
218
+ .pipe(
219
+ Effect.provide(CurrentDocLive),
220
+ setupRequestContextFromCurrent(),
221
+ Effect.scoped,
222
+ Effect.runPromise
223
+ ))
224
+
225
+ it("defaultValues only fill absent keys, and a stored null still reaches jitM", () =>
226
+ Effect
227
+ .gen(function*() {
228
+ const seen: JsonRecord[] = []
229
+ const repo = yield* makeRepo("LegacyDocShop", Shop, {
230
+ jitM: (json) => {
231
+ seen.push(json)
232
+ return json["updatedAt"] === null ? { ...json, updatedAt: "2024-06-08T00:00:00.000Z" } : json
233
+ },
234
+ config: {
235
+ defaultValues: {
236
+ vatRate: 19,
237
+ updatedAt: new Date("2024-01-01T00:00:00.000Z")
238
+ }
239
+ }
240
+ })
241
+
242
+ const items = yield* repo.all
243
+ expect(items).toHaveLength(2)
244
+
245
+ // absent key -> filled by defaultValues
246
+ const missing = items.find((_) => _.id === "shop-legacy")!
247
+ expect(missing.vatRate).toBe(19)
248
+ // present key -> the stored value wins, even when falsy
249
+ const present = items.find((_) => _.id === "shop-zero")!
250
+ expect(present.vatRate).toBe(0)
251
+
252
+ // defaultValues are merged *before* jitM runs
253
+ const filledSeen = seen.find((_) => _["id"] === "shop-legacy")!
254
+ expect(filledSeen["vatRate"]).toBe(19)
255
+ // ...but never overwrite a key the document has
256
+ expect(filledSeen["updatedAt"]).toBe("2024-06-03T00:00:00.000Z")
257
+
258
+ // a stored explicit `null` is NOT replaced by the default: it reaches jitM
259
+ const nullSeen = seen.find((_) => _["id"] === "shop-zero")!
260
+ expect(nullSeen["updatedAt"]).toBe(null)
261
+ expect(present.updatedAt.toISOString()).toBe("2024-06-08T00:00:00.000Z")
262
+ })
263
+ .pipe(
264
+ Effect.provide(DefaultsLive),
265
+ setupRequestContextFromCurrent(),
266
+ Effect.scoped,
267
+ Effect.runPromise
268
+ ))
269
+
270
+ /**
271
+ * The store boundary decode is deliberately **strict**: `jitM` is the one and
272
+ * 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.
276
+ */
277
+ it("a document jitM does not repair fails loudly at the store boundary", async () => {
278
+ const program = Effect
279
+ .gen(function*() {
280
+ // `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" })
283
+ })
284
+
285
+ return yield* repo.all
286
+ })
287
+ .pipe(
288
+ Effect.provide(TestLive),
289
+ setupRequestContextFromCurrent(),
290
+ Effect.scoped,
291
+ 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
+ })
308
+
309
+ it("_etag is not visible to jitM and survives the round trip", () => {
310
+ const seen: JsonRecord[] = []
311
+ const decode = makeStoredDecode<any>(
312
+ makeJsonDocumentCodec<any>(Shop),
313
+ (json) => {
314
+ seen.push(json)
315
+ return { ...json, vatRate: 19 }
316
+ }
317
+ )
318
+
319
+ const decoded = decode({ ...legacyDoc, _etag: "etag-1" } as any)
320
+
321
+ // jitM sees the document without the infra metadata
322
+ expect(seen).toHaveLength(1)
323
+ expect("_etag" in seen[0]!).toBe(false)
324
+ // ...and the etag is re-attached to the decoded Encoded document
325
+ expect(decoded._etag).toBe("etag-1")
326
+ expect(decoded.vatRate).toBe(19)
327
+ expect(decoded.createdAt).toBeInstanceOf(Date)
328
+ })
329
+ })