@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/CHANGELOG.md +21 -0
- package/dist/Store/Cosmos/query.d.ts.map +1 -1
- package/dist/Store/Cosmos/query.js +4 -4
- package/dist/Store/Cosmos.d.ts.map +1 -1
- package/dist/Store/Cosmos.js +7 -6
- package/dist/Store/Disk.d.ts.map +1 -1
- package/dist/Store/Disk.js +4 -4
- package/dist/Store/Memory.d.ts.map +1 -1
- package/dist/Store/Memory.js +11 -10
- package/dist/Store/SQL/Pg.js +17 -16
- package/dist/Store/SQL/query.d.ts.map +1 -1
- package/dist/Store/SQL/query.js +4 -4
- package/dist/Store/SQL.d.ts +9 -2
- package/dist/Store/SQL.d.ts.map +1 -1
- package/dist/Store/SQL.js +28 -29
- package/dist/Store/jsonDocument.d.ts +34 -11
- package/dist/Store/jsonDocument.d.ts.map +1 -1
- package/dist/Store/jsonDocument.js +53 -19
- package/package.json +2 -2
- package/src/Store/Cosmos/query.ts +3 -3
- package/src/Store/Cosmos.ts +11 -6
- package/src/Store/Disk.ts +4 -3
- package/src/Store/Memory.ts +14 -9
- package/src/Store/SQL/Pg.ts +21 -21
- package/src/Store/SQL/query.ts +3 -3
- package/src/Store/SQL.ts +37 -37
- package/src/Store/jsonDocument.ts +73 -18
- package/test/cosmos-query.test.ts +45 -0
- package/test/json-lower.test.ts +15 -1
- package/test/repository-legacy-document.test.ts +96 -31
- package/test/sql-store.test.ts +27 -1
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
|
-
/**
|
|
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
|
|
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>(
|
|
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.
|
|
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.
|
|
243
|
-
|
|
244
|
-
|
|
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.
|
|
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
|
|
319
|
-
|
|
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>(
|
|
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.
|
|
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.
|
|
591
|
-
|
|
592
|
-
|
|
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.
|
|
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
|
|
667
|
-
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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 =
|
|
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
|
|
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
|
|
67
|
-
*
|
|
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
|
-
|
|
94
|
+
schema: StoreConfig<E>["schema"],
|
|
77
95
|
jitM: StoreConfig<E>["jitM"]
|
|
78
|
-
):
|
|
79
|
-
if (
|
|
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
|
|
85
|
-
const
|
|
86
|
-
return
|
|
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
|
+
})
|
package/test/json-lower.test.ts
CHANGED
|
@@ -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 {
|
|
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>(
|
|
45
|
+
const decode = makeStoredDecode<any>(config?.schema, config?.jitM)
|
|
43
46
|
return {
|
|
44
47
|
...store,
|
|
45
|
-
all: Effect.
|
|
46
|
-
find: (id: any) =>
|
|
47
|
-
|
|
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
|
|
274
|
-
*
|
|
275
|
-
*
|
|
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
|
|
278
|
-
|
|
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
|
|
282
|
-
|
|
297
|
+
const store = yield* make<"id", any>("LegacyDocShop", "id", undefined, {
|
|
298
|
+
partitionValue: () => "primary",
|
|
299
|
+
schema: Shop,
|
|
300
|
+
jitM: jitMThatDoesNotRepair
|
|
283
301
|
})
|
|
284
302
|
|
|
285
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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)
|
package/test/sql-store.test.ts
CHANGED
|
@@ -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,
|