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

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, makeStoredDecode } from "./jsonDocument.ts"
18
+ import { decodeStoredMany, decodeStoredOption, 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
 
@@ -43,18 +43,22 @@ export class WithNsTransaction
43
43
  extends Context.Service<WithNsTransaction, WithNsTransactionFn>()("effect-app/WithNsTransaction")
44
44
  {}
45
45
 
46
- /** @internal */
46
+ /**
47
+ * Row -> raw stored JSON document: `data` parsed, `defaultValues` merged, `id`
48
+ * and `_etag` re-injected from their columns. Decoding it is a separate step
49
+ * (`makeStoredDecode`), so a document `jitM` does not repair fails as a typed
50
+ * `S.SchemaError` rather than throwing out of here.
51
+ *
52
+ * @internal
53
+ */
47
54
  export const parseRow = <Encoded extends FieldValues>(
48
55
  row: { id: string; _etag: string | null; data: string },
49
56
  idKey: PropertyKey,
50
- defaultValues: Partial<Encoded>,
51
- decode: (doc: PersistenceModelType<Encoded>) => PersistenceModelType<Encoded> = (doc) => doc
57
+ defaultValues: Partial<Encoded>
52
58
  ): PersistenceModelType<Encoded> => {
53
59
  const data = (typeof row.data === "string" ? JSON.parse(row.data) : row.data) as object
54
60
  const jsonDefaults = toJsonQueryValue(defaultValues) as Partial<Encoded>
55
- return decode(
56
- { ...jsonDefaults, ...data, [idKey]: row.id, _etag: row._etag ?? undefined } as PersistenceModelType<Encoded>
57
- )
61
+ return { ...jsonDefaults, ...data, [idKey]: row.id, _etag: row._etag ?? undefined } as PersistenceModelType<Encoded>
58
62
  }
59
63
 
60
64
  const parseSelectRow = (
@@ -95,7 +99,9 @@ function makeSQLStoreInt(system: DbSystem, dialect: SQLDialect, jsonColumnType:
95
99
  const defaultValues = json.toJson(config?.defaultValues ?? {}) as Partial<Encoded>
96
100
  const codec = makeJsonDocumentCodec<Encoded>(config?.schema)
97
101
  // read path: stored JSON -> defaultValues -> jitM -> JSON→Encoded decode
98
- const decodeStored = makeStoredDecode<Encoded>(codec, config?.jitM)
102
+ const decodeStored = makeStoredDecode<Encoded>(config?.schema, config?.jitM)
103
+ const decodeRow = (row: { id: string; _etag: string | null; data: string }) =>
104
+ decodeStored(parseRow<Encoded>(row, idKey, defaultValues))
99
105
 
100
106
  const resolveNamespace = !config?.allowNamespace
101
107
  ? Effect.succeed("primary")
@@ -218,9 +224,7 @@ function makeSQLStoreInt(system: DbSystem, dialect: SQLDialect, jsonColumnType:
218
224
  const sqlText = `SELECT id, _etag, data FROM "${tableName}" WHERE _namespace = ?`
219
225
  return exec(sqlText, [ns])
220
226
  .pipe(
221
- Effect.map((rows) =>
222
- (rows as any[]).map((r) => parseRow<Encoded>(r, idKey, defaultValues, decodeStored))
223
- ),
227
+ Effect.flatMap((rows) => Effect.fromResult(decodeStoredMany(rows as any[], decodeRow))),
224
228
  annotateDb({
225
229
  operation: "all",
226
230
  system,
@@ -239,12 +243,9 @@ function makeSQLStoreInt(system: DbSystem, dialect: SQLDialect, jsonColumnType:
239
243
  const sqlText = `SELECT id, _etag, data FROM "${tableName}" WHERE id = ? AND _namespace = ?`
240
244
  return exec(sqlText, [id, ns])
241
245
  .pipe(
242
- Effect.map((rows) => {
243
- const row = (rows as any[])[0]
244
- return row
245
- ? Option.some(parseRow<Encoded>(row, idKey, defaultValues, decodeStored))
246
- : Option.none()
247
- }),
246
+ Effect.flatMap((rows) =>
247
+ Effect.fromResult(decodeStoredOption(Option.fromNullishOr((rows as any[])[0]), decodeRow))
248
+ ),
248
249
  annotateDb({
249
250
  operation: "find",
250
251
  system,
@@ -302,9 +303,9 @@ function makeSQLStoreInt(system: DbSystem, dialect: SQLDialect, jsonColumnType:
302
303
  Effect.tap((q) => Effect.annotateCurrentSpan({ "db.query.text": q.sql })),
303
304
  Effect.flatMap((q) =>
304
305
  exec(q.sql, q.params).pipe(
305
- Effect.map((rows) => {
306
+ Effect.flatMap((rows) => {
306
307
  if (f.select) {
307
- return (rows as any[]).map((r) => {
308
+ return Effect.succeed((rows as any[]).map((r) => {
308
309
  const selected = parseSelectRow(r, idKey)
309
310
  return {
310
311
  ...Struct.pick(
@@ -313,10 +314,11 @@ function makeSQLStoreInt(system: DbSystem, dialect: SQLDialect, jsonColumnType:
313
314
  ),
314
315
  ...selected
315
316
  } as M
316
- })
317
+ }))
317
318
  }
318
- return (rows as any[]).map((r) =>
319
- parseRow<Encoded>(r, idKey, defaultValues, decodeStored) as any as M
319
+ return Effect.map(
320
+ Effect.fromResult(decodeStoredMany(rows as any[], decodeRow)),
321
+ (decoded) => decoded as any as M[]
320
322
  )
321
323
  })
322
324
  )
@@ -436,7 +438,9 @@ function makeSQLiteStorePerNs(
436
438
  const defaultValues = json.toJson(config?.defaultValues ?? {}) as Partial<Encoded>
437
439
  const codec = makeJsonDocumentCodec<Encoded>(config?.schema)
438
440
  // read path: stored JSON -> defaultValues -> jitM -> JSON→Encoded decode
439
- const decodeStored = makeStoredDecode<Encoded>(codec, config?.jitM)
441
+ const decodeStored = makeStoredDecode<Encoded>(config?.schema, config?.jitM)
442
+ const decodeRow = (row: { id: string; _etag: string | null; data: string }) =>
443
+ decodeStored(parseRow<Encoded>(row, idKey, defaultValues))
440
444
 
441
445
  const resolveNamespace = !config?.allowNamespace
442
446
  ? Effect.succeed("primary")
@@ -567,9 +571,7 @@ function makeSQLiteStorePerNs(
567
571
  const sqlText = `SELECT id, _etag, data FROM "${tableName}"`
568
572
  return exec(ns, sqlText)
569
573
  .pipe(
570
- Effect.map((rows) =>
571
- (rows as any[]).map((r) => parseRow<Encoded>(r, idKey, defaultValues, decodeStored))
572
- ),
574
+ Effect.flatMap((rows) => Effect.fromResult(decodeStoredMany(rows as any[], decodeRow))),
573
575
  annotateDb({
574
576
  operation: "all",
575
577
  system: "sqlite",
@@ -587,12 +589,9 @@ function makeSQLiteStorePerNs(
587
589
  const sqlText = `SELECT id, _etag, data FROM "${tableName}" WHERE id = ?`
588
590
  return exec(ns, sqlText, [id])
589
591
  .pipe(
590
- Effect.map((rows) => {
591
- const row = (rows as any[])[0]
592
- return row
593
- ? Option.some(parseRow<Encoded>(row, idKey, defaultValues, decodeStored))
594
- : Option.none()
595
- }),
592
+ Effect.flatMap((rows) =>
593
+ Effect.fromResult(decodeStoredOption(Option.fromNullishOr((rows as any[])[0]), decodeRow))
594
+ ),
596
595
  annotateDb({
597
596
  operation: "find",
598
597
  system: "sqlite",
@@ -650,9 +649,9 @@ function makeSQLiteStorePerNs(
650
649
  Effect.tap((q) => Effect.annotateCurrentSpan({ "db.query.text": q.sql })),
651
650
  Effect.flatMap((q) =>
652
651
  exec(ns, q.sql, q.params).pipe(
653
- Effect.map((rows) => {
652
+ Effect.flatMap((rows) => {
654
653
  if (f.select) {
655
- return (rows as any[]).map((r) => {
654
+ return Effect.succeed((rows as any[]).map((r) => {
656
655
  const selected = parseSelectRow(r, idKey)
657
656
  return {
658
657
  ...Struct.pick(
@@ -661,10 +660,11 @@ function makeSQLiteStorePerNs(
661
660
  ),
662
661
  ...selected
663
662
  } as M
664
- })
663
+ }))
665
664
  }
666
- return (rows as any[]).map((r) =>
667
- parseRow<Encoded>(r, idKey, defaultValues, decodeStored) as any as M
665
+ return Effect.map(
666
+ Effect.fromResult(decodeStoredMany(rows as any[], decodeRow)),
667
+ (decoded) => decoded as any as M[]
668
668
  )
669
669
  })
670
670
  )
@@ -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)
@@ -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
+ })
@@ -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
+ })
@@ -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)
@@ -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,