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

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/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
+ }
@@ -3,6 +3,7 @@ import { and, computed, make, project, projectComputed, relation, toFilter, wher
3
3
  import * as S from "effect-app/Schema"
4
4
  import { describe, expect, it } from "vitest"
5
5
  import { buildWhereCosmosQuery3 } from "../src/Store/Cosmos/query.js"
6
+ import { makeJsonLower } from "../src/Store/utils.js"
6
7
 
7
8
  class Order extends S.Class<Order>("Order")({
8
9
  id: S.String,
@@ -321,3 +322,47 @@ describe("cosmos query: aggregate (GROUP BY + agg functions)", () => {
321
322
  expect(result.query).toMatch(/GROUP BY f(?:\.address\.city|\["address"\]\["city"\])/)
322
323
  })
323
324
  })
325
+
326
+ describe("cosmos query defaultValues are already JSON-lowered", () => {
327
+ class Item extends S.Class<Item>("CosmosDefaultValuesItem")({
328
+ id: S.String,
329
+ at: S.Date
330
+ }) {}
331
+
332
+ const json = makeJsonLower({ schema: Item })
333
+ const at = new Date("2024-06-01T00:00:00.000Z")
334
+ const lowered = json.toJson({ at }) as Record<string, unknown>
335
+
336
+ it("does not re-encode store-lowered Date defaults on select-only filter", () => {
337
+ const result = buildWhereCosmosQuery3(
338
+ "id",
339
+ [],
340
+ "Item",
341
+ lowered,
342
+ ["id"],
343
+ undefined,
344
+ undefined,
345
+ undefined,
346
+ json
347
+ )
348
+ expect(result.query).toContain("SELECT f[\"id\"]")
349
+ })
350
+
351
+ it("embeds already-lowered Date defaults as JSON literals", () => {
352
+ const result = buildWhereCosmosQuery3(
353
+ "id",
354
+ [{ t: "where", path: "at", op: "eq", value: at }],
355
+ "Item",
356
+ lowered,
357
+ undefined,
358
+ undefined,
359
+ undefined,
360
+ undefined,
361
+ json
362
+ )
363
+ expect(result.query).toContain("?? \"2024-06-01T00:00:00.000Z\"")
364
+ expect(result.parameters).toEqual(
365
+ expect.arrayContaining([{ name: "@v0", value: "2024-06-01T00:00:00.000Z" }])
366
+ )
367
+ })
368
+ })
@@ -0,0 +1 @@
1
+ {"version":3,"file":"repository-legacy-document.test.d.ts","sourceRoot":"","sources":["../repository-legacy-document.test.ts"],"names":[],"mappings":""}
@@ -2,7 +2,7 @@ import type { Ops } from "effect-app/Model/filter/filterApi"
2
2
  import * as S from "effect-app/Schema"
3
3
  import * as Getter from "effect/SchemaGetter"
4
4
  import { describe, expect, it } from "vitest"
5
- import { jsonifyFilter } from "../src/Store/utils.js"
5
+ import { jsonifyFilter, makeJsonLower } from "../src/Store/utils.js"
6
6
 
7
7
  class Day {
8
8
  readonly ymd: string
@@ -113,3 +113,17 @@ describe("jsonifyFilter Encoded key paths", () => {
113
113
  ])
114
114
  })
115
115
  })
116
+
117
+ describe("makeJsonLower Date defaultValues", () => {
118
+ class Item extends S.Class<Item>("JsonLowerDateItem")({
119
+ id: S.String,
120
+ at: S.Date
121
+ }) {}
122
+
123
+ it("is not idempotent: a second pass over ISO strings throws Expected a valid Date", () => {
124
+ const json = makeJsonLower({ schema: Item })
125
+ const once = json.toJson({ at: new Date("2024-06-01T00:00:00.000Z") })
126
+ expect(once).toEqual({ at: "2024-06-01T00:00:00.000Z" })
127
+ expect(() => json.toJson(once)).toThrow(/Expected a valid Date/)
128
+ })
129
+ })
@@ -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
+ })
@@ -1,10 +1,11 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import type Sqlite from "better-sqlite3"
3
3
  import BetterSqlite from "better-sqlite3"
4
+ import * as S from "effect-app/Schema"
4
5
  import { describe, expect, it } from "vitest"
5
6
  import { parseRow } from "../src/Store/SQL.js"
6
7
  import { buildWhereSQLQuery, pgDialect, sqliteDialect } from "../src/Store/SQL/query.js"
7
- import { makeETag } from "../src/Store/utils.js"
8
+ import { makeETag, makeJsonLower } from "../src/Store/utils.js"
8
9
 
9
10
  const query = (db: Sqlite.Database, sql: string, params: unknown[] = []) =>
10
11
  db.prepare(sql).all(...params as any[]) as any[]
@@ -64,6 +65,31 @@ describe("SQL query builder (SQLite dialect)", () => {
64
65
  expect(result.params).toContain("2024-06-01")
65
66
  })
66
67
 
68
+ it("does not re-encode store-lowered Date defaultValues", () => {
69
+ class Item extends S.Class<Item>("SqlDefaultValuesItem")({
70
+ id: S.String,
71
+ at: S.Date
72
+ }) {}
73
+ const json = makeJsonLower({ schema: Item })
74
+ const at = new Date("2024-06-01T00:00:00.000Z")
75
+ const lowered = json.toJson({ at }) as Record<string, unknown>
76
+ const result = buildWhereSQLQuery(
77
+ sqliteDialect,
78
+ "id",
79
+ [{ t: "where", path: "at", op: "eq", value: at }],
80
+ "items",
81
+ lowered,
82
+ undefined,
83
+ undefined,
84
+ undefined,
85
+ undefined,
86
+ undefined,
87
+ json
88
+ )
89
+ expect(result.sql).toContain("COALESCE(")
90
+ expect(result.params).toContain("2024-06-01T00:00:00.000Z")
91
+ })
92
+
67
93
  it("where in Set binds array values", () => {
68
94
  const result = buildWhereSQLQuery(
69
95
  sqliteDialect,