@effect-app/infra 4.0.0-beta.325 → 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.
- package/CHANGELOG.md +21 -0
- package/dist/Store/Cosmos.d.ts.map +1 -1
- package/dist/Store/Cosmos.js +5 -3
- package/dist/Store/Disk.d.ts.map +1 -1
- package/dist/Store/Disk.js +13 -7
- package/dist/Store/Memory.d.ts +1 -1
- package/dist/Store/Memory.d.ts.map +1 -1
- package/dist/Store/Memory.js +7 -6
- package/dist/Store/SQL/Pg.d.ts.map +1 -1
- package/dist/Store/SQL/Pg.js +7 -5
- package/dist/Store/SQL.d.ts.map +1 -1
- package/dist/Store/SQL.js +12 -8
- package/dist/Store/jsonDocument.d.ts +32 -1
- package/dist/Store/jsonDocument.d.ts.map +1 -1
- package/dist/Store/jsonDocument.js +43 -1
- package/package.json +2 -2
- package/src/Store/Cosmos.ts +4 -2
- package/src/Store/Disk.ts +15 -6
- package/src/Store/Memory.ts +17 -5
- package/src/Store/SQL/Pg.ts +6 -4
- package/src/Store/SQL.ts +11 -7
- package/src/Store/jsonDocument.ts +46 -1
- package/test/dist/repository-legacy-document.test.d.ts.map +1 -0
- package/test/repository-legacy-document.test.ts +329 -0
- package/test/validateSample.test.ts +45 -29
package/src/Store/Memory.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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),
|
package/src/Store/SQL/Pg.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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 @@
|
|
|
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
|
+
})
|