diff --git a/.changeset/native-encoded-query-json.md b/.changeset/native-encoded-query-json.md
new file mode 100644
index 000000000..2686c0041
--- /dev/null
+++ b/.changeset/native-encoded-query-json.md
@@ -0,0 +1,9 @@
+---
+"effect-app": minor
+"@effect-app/infra": minor
+"@effect-app/vue-components": minor
+---
+
+Stop forcing Date/Map/Set Encoded shapes to JSON.
+
+`Schema.Date` / `ReadonlySet` / `ReadonlyMap` now keep native Encoded types (`Date`, `Set`, `Map`). Use `DateFromString`, `ReadonlySetFromArray`, and `ReadonlyMapFromArray` when the Encoded form must be JSON. The query DSL accepts those native values, including array ops (`includes` / `in` / `includes-any`) on `Date[]` and `ReadonlySet` fields. Memory, Disk, SQL, and Cosmos convert Encoded Date/Map/Set through `Schema.toCodecJson` on write/read; query parameters are lowered the same way.
diff --git a/packages/effect-app/src/Model/Repository/internal/internal.ts b/packages/effect-app/src/Model/Repository/internal/internal.ts
index 33db662b3..0bb5cba8b 100644
--- a/packages/effect-app/src/Model/Repository/internal/internal.ts
+++ b/packages/effect-app/src/Model/Repository/internal/internal.ts
@@ -581,7 +581,7 @@ export function makeRepoInternal<
.pipe(
Effect.andThen(
(items) =>
- S.decodeEffectConcurrently(S.Array(a.schema ?? schema))(items).pipe(
+ S.decodeEffectConcurrently(S.Array(S.toCodecJson(a.schema ?? schema)))(items).pipe(
provideRctx,
timeSchema("decode", name, "aggregate", items.length)
)
@@ -593,7 +593,7 @@ export function makeRepoInternal<
.pipe(
Effect.andThen(
(items) =>
- S.decodeEffectConcurrently(S.Array(a.schema ?? schema))(items).pipe(
+ S.decodeEffectConcurrently(S.Array(S.toCodecJson(a.schema ?? schema)))(items).pipe(
provideRctx,
timeSchema("decode", name, "project", items.length)
)
@@ -604,7 +604,7 @@ export function makeRepoInternal<
// TODO: mapFrom but need to support per field and dependencies
.pipe(
Effect.flatMap((items) =>
- S.decodeEffectConcurrently(S.Array(a.schema))(items).pipe(
+ S.decodeEffectConcurrently(S.Array(S.toCodecJson(a.schema)))(items).pipe(
Effect.map(Array.getSomes),
provideRctx,
timeSchema("decode", name, "collect", items.length)
@@ -737,7 +737,7 @@ export function makeRepoInternal<
queryRaw(schema: S.Codec, q: Q.RawQuery) {
return store.queryRaw(q).pipe(
Effect.flatMap((items) =>
- S.decodeEffectConcurrently(S.Array(schema))(items).pipe(
+ S.decodeEffectConcurrently(S.Array(S.toCodecJson(schema)))(items as readonly S.Json[]).pipe(
timeSchema("decode", name, undefined, items.length)
)
),
@@ -887,6 +887,7 @@ export function makeStore() {
: undefined,
{
...config,
+ schema,
partitionValue: config?.partitionValue
?? ((_) => "primary") /*(isIntegrationEvent(r) ? r.companyId : r.id*/
}
diff --git a/packages/effect-app/src/Model/filter/filterApi.ts b/packages/effect-app/src/Model/filter/filterApi.ts
index aba49c20b..83e014bc2 100644
--- a/packages/effect-app/src/Model/filter/filterApi.ts
+++ b/packages/effect-app/src/Model/filter/filterApi.ts
@@ -44,7 +44,7 @@ export type FilterR = {
op: Ops
path: string
- value: string // ToDO: Value[]
+ value: unknown
}
export type FilterResult =
diff --git a/packages/effect-app/src/Model/query/dsl.ts b/packages/effect-app/src/Model/query/dsl.ts
index d9efe2d04..3d114eb73 100644
--- a/packages/effect-app/src/Model/query/dsl.ts
+++ b/packages/effect-app/src/Model/query/dsl.ts
@@ -1157,7 +1157,11 @@ export const aggregate: {
return new Project({ current, schema, mode: "aggregate", aggregateMap } as any)
}
-type GetArV = T extends readonly (infer R)[] ? R : never
+type GetArV = T extends ReadonlySet ? R
+ : T extends readonly (infer R)[] ? R
+ : never
+
+type InValues = readonly T[] | ReadonlySet
export type FilterContinuations = {
<
@@ -1207,13 +1211,12 @@ export type FilterContinuations = {
<
TFieldValues extends FieldValues,
TFieldName extends FieldPath,
- const V extends readonly FieldPathValue[],
TFieldValuesRefined extends TFieldValues = TFieldValues,
E extends boolean = false
>(
path: TFieldName,
op: "in" | "notIn",
- value: V
+ value: InValues>
): (
current: IsCurrentInitial extends true ? Query
: QueryWhere
@@ -1249,7 +1252,7 @@ export type FilterContinuations = {
| "notIncludes-any"
| "includes-all"
| "notIncludes-all",
- value: readonly GetArV[]
+ value: InValues>
): (
current: IsCurrentInitial extends true ? Query
: QueryWhere
@@ -1318,12 +1321,12 @@ export type FilterContinuationsWithSubpath = {
TFieldName extends FieldPath,
TFieldValuesSub extends TFieldValues[TFieldName][number],
TFieldNameSub extends FieldPath,
- const V extends readonly FieldPathValue[]
+ V extends FieldPathValue
>(
subPath: TFieldName,
restPath: TFieldNameSub,
op: "in" | "notIn",
- value: V
+ value: InValues
): (
current: Query
) => QueryWhere
@@ -1357,7 +1360,7 @@ export type FilterContinuationsWithSubpath = {
| "notIncludes-any"
| "includes-all"
| "notIncludes-all",
- value: readonly GetArV[]
+ value: InValues>
): (
current: Query
) => QueryWhere
diff --git a/packages/effect-app/src/Schema/ext.ts b/packages/effect-app/src/Schema/ext.ts
index 0311be652..df0fafeb3 100644
--- a/packages/effect-app/src/Schema/ext.ts
+++ b/packages/effect-app/src/Schema/ext.ts
@@ -82,8 +82,6 @@ export const withDefaultParseOptions = (
return (input: any, options?: SchemaAST.ParseOptions) => run(input, { ...defaultParseOptions, ...options })
}) as Decode
-// TODO: v4 migration - Date is no longer by default encoded to string.
-
const DateString = S.String.annotate({
identifier: "DateOrInvalid",
description: "an ISO 8601 date string that will be decoded as a Date (may be invalid)",
@@ -107,12 +105,15 @@ export interface DateFromString extends S.decodeTo {}
* Encoding:
* - A `Date` is encoded as a `string`.
*
+ * Use this when the Encoded form must be JSON (`string`). Domain models should
+ * prefer {@link Date}, whose Encoded form is `Date`; JSON stores convert via
+ * `Schema.toCodecJson`.
+ *
* @since 4.0.0
*/
export const DateFromString: DateFromString = DateString.pipe(S.decodeTo(S.Date, SchemaTransformation.dateFromString))
-/** Like the default Schema `Date` but from String, with default helpers. */
-export const Date = extendM(DateFromString, (s) => ({
+const dateHelpers = (s: S.Date) => ({
/**
* Construction-only default `new Date()`. Applied only when the field is
* omitted from `.make(...)` input. NOT applied during decode — cannot be
@@ -127,37 +128,17 @@ export const Date = extendM(DateFromString, (s) => ({
* file-level note.
*/
withDecodingDefaultType: s.pipe(S.withDecodingDefaultType(Effect.sync(() => new global.Date())))
-}))
-
-const DateValidString = S.String.annotate({
- identifier: "Date",
- description: "a valid ISO 8601 date string that will be decoded as a Date",
- format: "date-time"
})
-// Schema.Date rejects invalid Dates since beta.91+; no separate isDateValid check needed.
-const DateValidFromString = DateValidString
- .pipe(
- S.decodeTo(S.Date, SchemaTransformation.dateFromString)
- )
+/** Like the default Schema `Date` (Encoded is `Date`) with default helpers. */
+export const Date = extendM(S.Date, dateHelpers)
-/** Like the default Schema `Date` (valid only) but from String, with default helpers. */
-export const DateValid = extendM(DateValidFromString, (s) => ({
- /**
- * Construction-only default `new Date()`. Applied only when the field is
- * omitted from `.make(...)` input. NOT applied during decode — cannot be
- * used to JIT-migrate database fields. See file-level note.
- */
- withConstructorDefault: s.pipe(S.withConstructorDefault(Effect.sync(() => new global.Date()))),
- /**
- * Decode-time default `new Date()`. **Discouraged for persisted data:** a
- * missing field may be data corruption, not an old-shape document; silently
- * substituting `new Date()` hides the problem. Prefer an explicit,
- * preferably versioned migration over a decode-time fallback. See
- * file-level note.
- */
- withDecodingDefaultType: s.pipe(S.withDecodingDefaultType(Effect.sync(() => new global.Date())))
-}))
+/**
+ * Alias of {@link Date}. Core `Schema.Date` already rejects invalid Dates.
+ *
+ * @deprecated Use {@link Date}.
+ */
+export const DateValid = Date
/** Like the default Schema `Boolean` but with default helpers. */
export const Boolean = Object.assign(S.Boolean, {
@@ -337,10 +318,10 @@ export const ReadonlyMapFromArray = (value: ValueSchema) =>
pipe(
- ReadonlySetFromArray(value),
+ S.ReadonlySet(value),
(s) =>
Object.assign(s, {
/**
@@ -365,13 +346,13 @@ export const ReadonlySet = (value: ValueSchema) =>
})
)
-/** Like the default Schema `ReadonlyMap` but from Array, with default helpers. */
+/** Like the default Schema `ReadonlyMap` (Encoded is `Map`) with default helpers. */
export const ReadonlyMap = (pair: {
readonly key: KeySchema
readonly value: ValueSchema
}) =>
pipe(
- ReadonlyMapFromArray(pair),
+ S.ReadonlyMap(pair.key, pair.value),
(s) =>
Object.assign(s, {
/**
@@ -503,9 +484,9 @@ export type WithDefaults = (
// export type UnionToIntersection3 = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I
// : never
-/** Union of core `Schema.Date` (Date objects) and string-encoded `Date`, with default helpers. */
+/** Union of core `Schema.Date` (Date objects) and string-encoded `DateFromString`, with default helpers. */
export const inputDate = extendM(
- S.Union([S.Date, Date]),
+ S.Union([S.Date, DateFromString]),
(s) => ({
/**
* Construction-only default `new Date()`. Applied only when the field is
diff --git a/packages/effect-app/src/Store.ts b/packages/effect-app/src/Store.ts
index eea19b6c4..e554b685b 100644
--- a/packages/effect-app/src/Store.ts
+++ b/packages/effect-app/src/Store.ts
@@ -11,7 +11,7 @@ import type { FieldPath } from "./Model/filter/types/path/index.ts"
import type { AggregateIrExpression, ComputedProjectionIrExpression, RawQuery } from "./Model/query.ts"
import type * as Option from "./Option.ts"
import * as RequestScopedDependencies from "./RequestScopedDependencies.ts"
-import { NonEmptyString255 } from "./Schema.ts"
+import { NonEmptyString255, type Top as SchemaTop } from "./Schema.ts"
/**
* Adapter-neutral unique-key definition for stores that support unique indexes,
@@ -46,6 +46,11 @@ export interface StoreConfig {
* Unique indexes, mainly for CosmosDB
*/
uniqueKeys?: UniqueKey[]
+ /**
+ * Domain schema whose Encoded shape is stored. JSON adapters use
+ * `Schema.toCodecJson(Schema.toEncoded(schema))` so Date/Map/Set round-trip.
+ */
+ schema?: SchemaTop
}
export type SupportedValues = string | boolean | number | null
diff --git a/packages/effect-app/test/schema.test.ts b/packages/effect-app/test/schema.test.ts
index 976220a7a..4d5083f77 100644
--- a/packages/effect-app/test/schema.test.ts
+++ b/packages/effect-app/test/schema.test.ts
@@ -265,14 +265,13 @@ test("TaggedUnion match dispatches on _tag", () => {
S.TaggedStruct("A", { a: S.String }),
S.TaggedStruct("B", { b: S.Finite })
])
- type T = S.Schema.Type
const matcher = schema.match({
A: (v) => `got A: ${v.a}`,
B: (v) => `got B: ${v.b}`
})
- expect(matcher({ _tag: "A", a: "hello" } as T)).toBe("got A: hello")
- expect(matcher({ _tag: "B", b: 42 } as T)).toBe("got B: 42")
+ expect(matcher({ _tag: "A", a: "hello" })).toBe("got A: hello")
+ expect(matcher({ _tag: "B", b: 42 })).toBe("got B: 42")
})
test("TaggedUnion with single member", () => {
@@ -319,8 +318,7 @@ test("TaggedUnion with encodeKeys renaming a non-tag key", () => {
expect(decoded2).toEqual({ _tag: "B", lastName: 42 })
// encode back to snake_case
- type T = S.Schema.Type
- const encoded = S.encodeSync(schema)({ _tag: "A", firstName: "Alice" } as T)
+ const encoded = S.encodeSync(schema)({ _tag: "A", firstName: "Alice" })
expect(encoded).toEqual({ _tag: "A", first_name: "Alice" })
// guards work on decoded values
@@ -387,7 +385,7 @@ describe("ReadonlySetFromArray", () => {
describe("ReadonlyMapFromArray", () => {
test("decodes an array of tuples to a Map", () => {
- const schema = S.ReadonlyMap({ key: S.String, value: S.Finite })
+ const schema = S.ReadonlyMapFromArray({ key: S.String, value: S.Finite })
const decoded = S.decodeUnknownSync(schema)([["a", 1], ["b", 2]])
expect(decoded).toEqual(new Map([["a", 1], ["b", 2]]))
})
@@ -445,11 +443,18 @@ describe("ReadonlySet (with withConstructorDefault)", () => {
expect(made.items).toEqual(new Set())
})
- test("decodes array with NumberFromString values", () => {
+ test("decodes a Set with NumberFromString values", () => {
const schema = S.ReadonlySet(S.NumberFromString)
- const decoded = S.decodeUnknownSync(schema)(["1", "2"])
+ const decoded = S.decodeUnknownSync(schema)(new Set(["1", "2"]))
expect(decoded).toEqual(new Set([1, 2]))
})
+
+ test("Encoded is a Set, not an array", () => {
+ const schema = S.ReadonlySet(S.String)
+ const encoded = S.encodeSync(schema)(new Set(["a"]))
+ expect(encoded).toEqual(new Set(["a"]))
+ expectTypeOf(encoded).toEqualTypeOf>()
+ })
})
describe("ReadonlyMap (with withConstructorDefault)", () => {
@@ -460,11 +465,18 @@ describe("ReadonlyMap (with withConstructorDefault)", () => {
expect(made.items).toEqual(new Map())
})
- test("decodes array of tuples with NumberFromString keys", () => {
+ test("decodes a Map with NumberFromString keys", () => {
const schema = S.ReadonlyMap({ key: S.NumberFromString, value: S.String })
- const decoded = S.decodeUnknownSync(schema)([["1", "one"]])
+ const decoded = S.decodeUnknownSync(schema)(new Map([["1", "one"]]))
expect(decoded).toEqual(new Map([[1, "one"]]))
})
+
+ test("Encoded is a Map, not an array of tuples", () => {
+ const schema = S.ReadonlyMap({ key: S.String, value: S.Finite })
+ const encoded = S.encodeSync(schema)(new Map([["a", 1]]))
+ expect(encoded).toEqual(new Map([["a", 1]]))
+ expectTypeOf(encoded).toEqualTypeOf>()
+ })
})
describe("JSON Schema", () => {
@@ -506,8 +518,18 @@ describe("JSON Schema", () => {
})
})
- test("Date has identifier DateOrInvalid and ISO 8601 description", () => {
+ test("Date Encoded is Date; JSON codec encodes ISO strings", () => {
+ const d = new Date("2024-01-01T00:00:00.000Z")
+ expect(S.decodeUnknownSync(S.Date)(d)).toBe(d)
+ expect(S.encodeSync(S.Date)(d)).toBe(d)
+ expect(S.encodeSync(S.toCodecJson(S.Date))(d)).toBe("2024-01-01T00:00:00.000Z")
const doc = S.toJsonSchemaDocument(S.Date)
+ expect(doc.dialect).toBe("draft-2020-12")
+ expect(doc.schema).toEqual({ type: "string" })
+ })
+
+ test("DateFromString keeps string Encoded", () => {
+ const doc = S.toJsonSchemaDocument(S.DateFromString)
expect(doc).toStrictEqual({
dialect: "draft-2020-12",
schema: { "$ref": "#/$defs/DateOrInvalid" },
@@ -521,21 +543,6 @@ describe("JSON Schema", () => {
})
})
- test("DateValid has identifier Date and ISO 8601 description", () => {
- const doc = S.toJsonSchemaDocument(S.DateValid)
- expect(doc).toStrictEqual({
- dialect: "draft-2020-12",
- schema: { "$ref": "#/$defs/Date" },
- definitions: {
- Date: {
- type: "string",
- description: "a valid ISO 8601 date string that will be decoded as a Date",
- format: "date-time"
- }
- }
- })
- })
-
test("PhoneNumber has format phone", () => {
const doc = specialJsonSchemaDocument(S.PhoneNumber)
expect(doc).toStrictEqual({
diff --git a/packages/infra/examples/query.ts b/packages/infra/examples/query.ts
index 37d0d2652..71ef736f3 100644
--- a/packages/infra/examples/query.ts
+++ b/packages/infra/examples/query.ts
@@ -76,7 +76,7 @@ const program = Effect.gen(function*() {
and("_tag", "Something"),
or(
where("displayName", "Riley"),
- and("n", "gt", "2021-01-01T00:00:00Z"), // TODO: work with To type translation, so Date?
+ and("n", "gt", new Date("2021-01-01T00:00:00Z")),
and("_tag", "Something")
),
order("displayName"),
@@ -90,7 +90,7 @@ const program = Effect.gen(function*() {
and("_tag", "Something"),
or(
where("displayName", "Riley"),
- and("n", "gt", "2021-01-01T00:00:00Z"), // TODO: work with To type translation, so Date?
+ and("n", "gt", new Date("2021-01-01T00:00:00Z")),
and("_tag", "Something")
),
order("displayName"),
@@ -112,7 +112,7 @@ expectTypeOf(test1).toEqualTypeOf<
readonly _tag: "Something"
readonly id: string
readonly displayName: string
- readonly n: string
+ readonly n: Date
readonly union: {
readonly _tag: "string"
readonly value: string
@@ -129,7 +129,7 @@ expectTypeOf(testneq1).toEqualTypeOf<
readonly _tag: "Something"
readonly id: string
readonly displayName: string
- readonly n: string
+ readonly n: Date
readonly union: {
readonly _tag: "number"
readonly value: number
diff --git a/packages/infra/src/Store/Cosmos.ts b/packages/infra/src/Store/Cosmos.ts
index fbde39270..d1a336c42 100644
--- a/packages/infra/src/Store/Cosmos.ts
+++ b/packages/infra/src/Store/Cosmos.ts
@@ -19,6 +19,7 @@ import { DatabaseError, OptimisticConcurrencyException } from "../errors.ts"
import { InfraLogger } from "../logger.ts"
import { annotateCosmosResponse, annotateDb } from "../otel.ts"
import { buildWhereCosmosQuery3, logQuery } from "./Cosmos/query.ts"
+import { makeJsonDocumentCodec } from "./jsonDocument.ts"
const makeMapId =
(idKey: IdKey) => ({ [idKey]: id, ...e }: Encoded) => ({
@@ -96,6 +97,8 @@ const makeCosmosStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
) {
const mapId = makeMapId(idKey)
const mapReverseId = makeReverseMapId(idKey)
+ const codec = makeJsonDocumentCodec(config?.schema)
+ const fromStored = (raw: Encoded) => codec.decode({ ...config?.defaultValues, ...mapReverseId(raw as any) })
type PM = PersistenceModelType
type PMCosmos = PersistenceModelType & { id: string }>
const containerId = `${prefix}${name}`
@@ -205,7 +208,7 @@ const makeCosmosStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
dropUndefinedT({
operationType: "Create" as const,
resourceBody: {
- ...Struct.omit(x, ["_etag", idKey]),
+ ...Struct.omit(codec.encode(x), ["_etag", idKey]),
id: x[idKey],
_partitionKey: nsPartitionValue(ns, x)
}
@@ -217,7 +220,7 @@ const makeCosmosStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
operationType: "Replace" as const,
id: x[idKey],
resourceBody: {
- ...Struct.omit(x, ["_etag", idKey]),
+ ...Struct.omit(codec.encode(x), ["_etag", idKey]),
id: x[idKey],
_partitionKey: nsPartitionValue(ns, x)
},
@@ -314,7 +317,7 @@ const makeCosmosStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
onNone: () => ({
operationType: "Create" as const,
resourceBody: {
- ...Struct.omit(x, ["_etag", idKey]),
+ ...Struct.omit(codec.encode(x), ["_etag", idKey]),
id: x[idKey],
_partitionKey: nsPartitionValue(ns, x)
}
@@ -325,7 +328,7 @@ const makeCosmosStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
operationType: "Replace" as const,
id: x[idKey],
resourceBody: {
- ...Struct.omit(x, ["_etag", idKey]),
+ ...Struct.omit(codec.encode(x), ["_etag", idKey]),
id: x[idKey],
_partitionKey: nsPartitionValue(ns, x)
},
@@ -447,7 +450,7 @@ const makeCosmosStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
container.items.query(q, { partitionKey: nsBasePartitionKey(ns) }).fetchAll()
)
yield* annotateFeed(response)
- return response.resources.map((_) => ({ ...defaultValues, ...mapReverseId(_) }))
+ return response.resources.map((_) => fromStored(_ as unknown as Encoded))
})
.pipe(
annotateDb({
@@ -520,7 +523,7 @@ const makeCosmosStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
container.items.query<{ f: M }>(q, { partitionKey: nsBasePartitionKey(ns) }).fetchAll()
)
yield* annotateFeed(response)
- return response.resources.map(({ f }) => ({ ...defaultValues, ...mapReverseId(f as any) }) as any)
+ return response.resources.map(({ f }) => fromStored(f as Encoded) as any)
})
.pipe(
annotateDb({
@@ -546,7 +549,7 @@ const makeCosmosStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
)
yield* annotateItem(response)
return Option.fromNullishOr(response.resource).pipe(
- Option.map((_) => ({ ...defaultValues, ...mapReverseId(_) }))
+ Option.map((_) => fromStored(_))
)
})
.pipe(annotateDb({
@@ -573,12 +576,12 @@ const makeCosmosStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
{
onNone: () =>
container.items.create({
- ...mapId(e),
+ ...mapId(codec.encode(e)),
_partitionKey: nsPartitionValue(ns, e)
}),
onSome: (eTag) =>
container.item(e[idKey], nsPartitionValue(ns, e)).replace(
- { ...mapId(e), _partitionKey: nsPartitionValue(ns, e) },
+ { ...mapId(codec.encode(e)), _partitionKey: nsPartitionValue(ns, e) },
{
accessCondition: {
type: "IfMatch",
diff --git a/packages/infra/src/Store/Cosmos/query.ts b/packages/infra/src/Store/Cosmos/query.ts
index ebcc37983..e7589f059 100644
--- a/packages/infra/src/Store/Cosmos/query.ts
+++ b/packages/infra/src/Store/Cosmos/query.ts
@@ -9,6 +9,7 @@ import type { SupportedValues } from "effect-app/Store"
import { assertUnreachable } from "effect-app/utils"
import { InfraLogger } from "../../logger.ts"
import { isRelationCheck } from "../codeFilter.ts"
+import { jsonifyFilter, toJsonQueryValue } from "../utils.ts"
export function logQuery(q: {
query: string
@@ -62,6 +63,8 @@ export function buildWhereCosmosQuery3(
skip?: number,
limit?: number
) {
+ filter = jsonifyFilter(filter)
+ defaultValues = toJsonQueryValue(defaultValues) as Record
const statement = (x: FilterR, i: number) => {
if (x.path === idKey) {
x = { ...x, path: "id" }
@@ -89,21 +92,17 @@ export function buildWhereCosmosQuery3(
return `(NOT ARRAY_CONTAINS(${k}, ${v}))`
case "includes-any":
- return `ARRAY_CONTAINS_ANY(${k}, ${
- (x.value as unknown as readonly unknown[]).map((_, i) => `${v}__${i}`).join(", ")
- })`
+ return `ARRAY_CONTAINS_ANY(${k}, ${(x.value as readonly unknown[]).map((_, i) => `${v}__${i}`).join(", ")})`
case "notIncludes-any":
return `(NOT ARRAY_CONTAINS_ANY(${k}, ${
- (x.value as unknown as readonly unknown[]).map((_, i) => `${v}__${i}`).join(", ")
+ (x.value as readonly unknown[]).map((_, i) => `${v}__${i}`).join(", ")
}))`
case "includes-all":
- return `ARRAY_CONTAINS_ALL(${k}, ${
- (x.value as unknown as readonly unknown[]).map((_, i) => `${v}__${i}`).join(", ")
- })`
+ return `ARRAY_CONTAINS_ALL(${k}, ${(x.value as readonly unknown[]).map((_, i) => `${v}__${i}`).join(", ")})`
case "notIncludes-all":
return `(NOT ARRAY_CONTAINS_ALL(${k}, ${
- (x.value as unknown as readonly unknown[]).map((_, i) => `${v}__${i}`).join(", ")
+ (x.value as readonly unknown[]).map((_, i) => `${v}__${i}`).join(", ")
}))`
case "contains":
diff --git a/packages/infra/src/Store/Disk.ts b/packages/infra/src/Store/Disk.ts
index 0d5bf3f05..862e33dbb 100644
--- a/packages/infra/src/Store/Disk.ts
+++ b/packages/infra/src/Store/Disk.ts
@@ -10,6 +10,7 @@ import * as Console from "effect/Console"
import { flow } from "effect/Function"
import * as Semaphore from "effect/Semaphore"
import { annotateDb } from "../otel.ts"
+import { makeJsonDocumentCodec } from "./jsonDocument.ts"
import { makeMemoryStoreInt } from "./Memory.ts"
function makeDiskStoreInt(
@@ -19,9 +20,11 @@ function makeDiskStoreInt, E, R>,
- defaultValues?: Partial
+ defaultValues?: Partial,
+ schema?: StoreConfig["schema"]
) {
type PM = PersistenceModelType
+ const codec = makeJsonDocumentCodec(schema)
return Effect.gen(function*() {
if (namespace !== "primary") {
dir = dir + "/" + namespace
@@ -44,7 +47,7 @@ function makeDiskStoreInt
- Effect.sync(() => JSON.parse(x) as PM[]).pipe(
+ Effect.sync(() => (JSON.parse(x) as PM[]).map((row) => codec.decode(row))).pipe(
annotateDb({
operation: "read.parse",
system: "disk",
@@ -67,7 +70,7 @@ function makeDiskStoreInt) =>
Effect
- .sync(() => JSON.stringify([...v], undefined, 2))
+ .sync(() => JSON.stringify([...v].map((row) => codec.encode(row)), undefined, 2))
.pipe(
annotateDb({
operation: "stringify",
@@ -117,7 +120,8 @@ function makeDiskStoreInt, E, R>,
config?: StoreConfig
) {
- const primary = yield* makeDiskStoreInt(prefix, idKey, "primary", dir, name, seed, config?.defaultValues).pipe(
- Effect.orDie
+ const primary = yield* makeDiskStoreInt(
+ prefix,
+ idKey,
+ "primary",
+ dir,
+ name,
+ seed,
+ config?.defaultValues,
+ config?.schema
)
+ .pipe(
+ Effect.orDie
+ )
const stores = new Map>([["primary", primary]])
const ctx = yield* Effect.context()
const semaphores = new Map()
@@ -204,7 +218,8 @@ export function makeDiskStore({ prefix }: StorageConfig, dir: string) {
dir,
name,
seed,
- config?.defaultValues
+ config?.defaultValues,
+ config?.schema
)
.pipe(
Effect.orDie,
diff --git a/packages/infra/src/Store/Memory.ts b/packages/infra/src/Store/Memory.ts
index 059488d41..e6fffe584 100644
--- a/packages/infra/src/Store/Memory.ts
+++ b/packages/infra/src/Store/Memory.ts
@@ -18,7 +18,8 @@ import * as Struct from "effect/Struct"
import { InfraLogger } from "../logger.ts"
import { annotateDb } from "../otel.ts"
import { codeFilter, codeFilter3_ } from "./codeFilter.ts"
-import { get, makeUpdateETag } from "./utils.ts"
+import { makeJsonDocumentCodec } from "./jsonDocument.ts"
+import { get, jsonifyFilter, makeUpdateETag, toJsonQueryValue } from "./utils.ts"
export { get } from "./utils.ts"
@@ -332,25 +333,35 @@ export function makeMemoryStoreInt, E, R>,
- _defaultValues?: Partial
+ _defaultValues?: Partial,
+ schema?: StoreConfig["schema"]
) {
type PM = PersistenceModelType
return Effect.gen(function*() {
const updateETag = makeUpdateETag(modelName)
+ const codec = makeJsonDocumentCodec(schema)
+ const encodeDoc = (e: Encoded | PM): PM => codec.encode({ _etag: undefined, ...e })
+ const decodeDoc = (e: PM): PM => codec.decode(e)
const items_ = yield* seed ?? Effect.sync(() => [])
- const defaultValues = _defaultValues ?? {}
+ const encodedDefaults = toJsonQueryValue(_defaultValues ?? {}) as Partial
- const items = new Map([...items_].map((_) => [_[idKey], { _etag: undefined, ...defaultValues, ..._ }] as const))
+ const items = new Map(
+ [...items_].map((_) => {
+ const encoded = encodeDoc({ ...encodedDefaults, ..._ })
+ return [encoded[idKey], encoded] as const
+ })
+ )
const store = Ref.makeUnsafe>(items)
const sem = Semaphore.makeUnsafe(1)
const withPermit = sem.withPermits(1)
const values = Effect.map(Ref.get(store), (s) => s.values())
- const all = Effect.map(values, Array.fromIterable)
+ const allStored = Effect.map(values, Array.fromIterable)
+ const all = Effect.map(allStored, (rows) => rows.map(decodeDoc))
const batchSet = (items: NonEmptyReadonlyArray) =>
Effect
- .forEach(items, (i) => Effect.flatMap(s.find(i[idKey]), (current) => updateETag(i, idKey, current)))
+ .forEach(items, (i) => Effect.flatMap(s.find(i[idKey]), (current) => updateETag(encodeDoc(i), idKey, current)))
.pipe(
Effect
.tap((items) =>
@@ -368,7 +379,7 @@ export function makeMemoryStoreInt _),
+ .map((items) => items.map(decodeDoc) as unknown as NonEmptyReadonlyArray),
withPermit
)
@@ -414,7 +425,7 @@ export function makeMemoryStoreInt Option.fromNullishOr(_.get(id))),
+ Effect.map((_) => Option.fromNullishOr(_.get(id)).pipe(Option.map(decodeDoc))),
annotateDb({
operation: "find",
system: "memory",
@@ -424,11 +435,16 @@ export function makeMemoryStoreInt
- all
+ filter: (f: FilterArgs) =>
+ allStored
.pipe(
- Effect.tap(() => logQuery(f, defaultValues)),
- Effect.map(memFilter(f)),
+ Effect.tap(() => logQuery(f, encodedDefaults)),
+ Effect.map(memFilter({ ...f, filter: f.filter ? jsonifyFilter(f.filter) : f.filter })),
+ Effect.map((rows): (U extends undefined ? Encoded : Pick)[] =>
+ f.select
+ ? rows as (U extends undefined ? Encoded : Pick)[]
+ : rows.map(decodeDoc) as (U extends undefined ? Encoded : Pick)[]
+ ),
annotateDb({
operation: "filter",
system: "memory",
@@ -441,14 +457,15 @@ export function makeMemoryStoreInt updateETag(e, idKey, current)),
+ Effect.flatMap((current) => updateETag(encodeDoc(e), idKey, current.pipe(Option.map(encodeDoc)))),
Effect
- .tap((e) =>
+ .tap((stored) =>
Ref.get(store).pipe(
- Effect.map((_) => new Map([..._, [e[idKey], e]])),
+ Effect.map((_) => new Map([..._, [stored[idKey], stored]])),
Effect.flatMap((_) => Ref.set(store, _))
)
),
+ Effect.map(decodeDoc),
withPermit,
annotateDb({
operation: "set",
@@ -523,7 +540,8 @@ export const makeMemoryStore = () => ({
idKey,
"primary",
seed,
- config?.defaultValues
+ config?.defaultValues,
+ config?.schema
)
const ctx = yield* Effect.context()
const stores = new Map([["primary", primary]])
@@ -543,7 +561,7 @@ export const makeMemoryStore = () => ({
if (config?.allowNamespace && !config.allowNamespace(namespace)) {
throw new Error(`Namespace ${namespace} not allowed!`)
}
- return makeMemoryStoreInt(modelName, idKey, namespace, seed, config?.defaultValues)
+ return makeMemoryStoreInt(modelName, idKey, namespace, seed, config?.defaultValues, config?.schema)
.pipe(
Effect.orDie,
Effect.provide(ctx),
diff --git a/packages/infra/src/Store/SQL.ts b/packages/infra/src/Store/SQL.ts
index f612a2e50..ebd09d301 100644
--- a/packages/infra/src/Store/SQL.ts
+++ b/packages/infra/src/Store/SQL.ts
@@ -15,6 +15,7 @@ import { SqlClient } from "effect/unstable/sql"
import { DatabaseError, OptimisticConcurrencyException } from "../errors.ts"
import { InfraLogger } from "../logger.ts"
import { annotateDb, type DbSystem } from "../otel.ts"
+import { makeJsonDocumentCodec } from "./jsonDocument.ts"
import { buildWhereSQLQuery, logQuery, type SQLDialect, sqliteDialect } from "./SQL/query.ts"
import { makeETag } from "./utils.ts"
@@ -46,10 +47,13 @@ export class WithNsTransaction
export const parseRow = (
row: { id: string; _etag: string | null; data: string },
idKey: PropertyKey,
- defaultValues: Partial
+ defaultValues: Partial,
+ decode: (doc: PersistenceModelType) => PersistenceModelType = (doc) => doc
): PersistenceModelType => {
const data = (typeof row.data === "string" ? JSON.parse(row.data) : row.data) as object
- return { ...defaultValues, ...data, [idKey]: row.id, _etag: row._etag ?? undefined } as PersistenceModelType
+ return decode(
+ { ...defaultValues, ...data, [idKey]: row.id, _etag: row._etag ?? undefined } as PersistenceModelType
+ )
}
const parseSelectRow = (
@@ -87,6 +91,7 @@ function makeSQLStoreInt(system: DbSystem, dialect: SQLDialect, jsonColumnType:
type PM = PersistenceModelType
const tableName = `${prefix}${name}`
const defaultValues = config?.defaultValues ?? {}
+ const codec = makeJsonDocumentCodec(config?.schema)
const resolveNamespace = !config?.allowNamespace
? Effect.succeed("primary")
@@ -112,11 +117,11 @@ function makeSQLStoreInt(system: DbSystem, dialect: SQLDialect, jsonColumnType:
)
const toRow = (e: PM) => {
- const newE = makeETag(e)
+ const newE = makeETag(codec.encode(e))
const id = newE[idKey] as string
const { _etag, [idKey]: _id, ...rest } = newE as any
const data = JSON.stringify(rest)
- return { id, _etag: newE._etag!, data, item: newE }
+ return { id, _etag: newE._etag!, data, item: codec.decode(newE) }
}
const exec = (query: string, params?: readonly unknown[]) =>
@@ -209,7 +214,9 @@ function makeSQLStoreInt(system: DbSystem, dialect: SQLDialect, jsonColumnType:
const sqlText = `SELECT id, _etag, data FROM "${tableName}" WHERE _namespace = ?`
return exec(sqlText, [ns])
.pipe(
- Effect.map((rows) => (rows as any[]).map((r) => parseRow(r, idKey, defaultValues))),
+ Effect.map((rows) =>
+ (rows as any[]).map((r) => parseRow(r, idKey, defaultValues, codec.decode))
+ ),
annotateDb({
operation: "all",
system,
@@ -231,7 +238,7 @@ function makeSQLStoreInt(system: DbSystem, dialect: SQLDialect, jsonColumnType:
Effect.map((rows) => {
const row = (rows as any[])[0]
return row
- ? Option.some(parseRow(row, idKey, defaultValues))
+ ? Option.some(parseRow(row, idKey, defaultValues, codec.decode))
: Option.none()
}),
annotateDb({
@@ -303,7 +310,9 @@ function makeSQLStoreInt(system: DbSystem, dialect: SQLDialect, jsonColumnType:
} as M
})
}
- return (rows as any[]).map((r) => parseRow(r, idKey, defaultValues) as any as M)
+ return (rows as any[]).map((r) =>
+ parseRow(r, idKey, defaultValues, codec.decode) as any as M
+ )
})
)
),
@@ -419,6 +428,7 @@ function makeSQLiteStorePerNs(
type PM = PersistenceModelType
const tableName = `${prefix}${name}`
const defaultValues = config?.defaultValues ?? {}
+ const codec = makeJsonDocumentCodec(config?.schema)
const resolveNamespace = !config?.allowNamespace
? Effect.succeed("primary")
@@ -430,11 +440,11 @@ function makeSQLiteStorePerNs(
}))
const toRow = (e: PM) => {
- const newE = makeETag(e)
+ const newE = makeETag(codec.encode(e))
const id = newE[idKey] as string
const { _etag, [idKey]: _id, ...rest } = newE as any
const data = JSON.stringify(rest)
- return { id, _etag: newE._etag!, data, item: newE }
+ return { id, _etag: newE._etag!, data, item: codec.decode(newE) }
}
const exec = (ns: string, query: string, params?: readonly unknown[]) =>
@@ -549,7 +559,9 @@ function makeSQLiteStorePerNs(
const sqlText = `SELECT id, _etag, data FROM "${tableName}"`
return exec(ns, sqlText)
.pipe(
- Effect.map((rows) => (rows as any[]).map((r) => parseRow(r, idKey, defaultValues))),
+ Effect.map((rows) =>
+ (rows as any[]).map((r) => parseRow(r, idKey, defaultValues, codec.decode))
+ ),
annotateDb({
operation: "all",
system: "sqlite",
@@ -570,7 +582,7 @@ function makeSQLiteStorePerNs(
Effect.map((rows) => {
const row = (rows as any[])[0]
return row
- ? Option.some(parseRow(row, idKey, defaultValues))
+ ? Option.some(parseRow(row, idKey, defaultValues, codec.decode))
: Option.none()
}),
annotateDb({
@@ -641,7 +653,9 @@ function makeSQLiteStorePerNs(
} as M
})
}
- return (rows as any[]).map((r) => parseRow(r, idKey, defaultValues) as any as M)
+ return (rows as any[]).map((r) =>
+ parseRow(r, idKey, defaultValues, codec.decode) as any as M
+ )
})
)
),
diff --git a/packages/infra/src/Store/SQL/Pg.ts b/packages/infra/src/Store/SQL/Pg.ts
index 4fffa4e8c..afba64d89 100644
--- a/packages/infra/src/Store/SQL/Pg.ts
+++ b/packages/infra/src/Store/SQL/Pg.ts
@@ -12,6 +12,7 @@ import { SqlClient } from "effect/unstable/sql"
import { DatabaseError, OptimisticConcurrencyException } from "../../errors.ts"
import { InfraLogger } from "../../logger.ts"
import { annotateDb } from "../../otel.ts"
+import { makeJsonDocumentCodec } from "../jsonDocument.ts"
import { makeETag } from "../utils.ts"
import { buildWhereSQLQuery, logQuery, pgDialect } from "./query.ts"
@@ -36,10 +37,13 @@ const preserveStoreError = (e: unknown): DatabaseError | OptimisticConcurrencyEx
const parseRow = (
row: { id: string; _etag: string | null; data: unknown },
idKey: PropertyKey,
- defaultValues: Partial
+ defaultValues: Partial,
+ decode: (doc: PersistenceModelType) => PersistenceModelType = (doc) => doc
): PersistenceModelType => {
const data = (typeof row.data === "string" ? JSON.parse(row.data) : row.data) as object
- return { ...defaultValues, ...data, [idKey]: row.id, _etag: row._etag ?? undefined } as PersistenceModelType
+ return decode(
+ { ...defaultValues, ...data, [idKey]: row.id, _etag: row._etag ?? undefined } as PersistenceModelType
+ )
}
const parseSelectRow = (
@@ -71,6 +75,7 @@ const makePgStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
type PM = PersistenceModelType
const tableName = `${prefix}${name}`
const defaultValues = config?.defaultValues ?? {}
+ const codec = makeJsonDocumentCodec(config?.schema)
const resolveNamespace = !config?.allowNamespace
? Effect.succeed("primary")
@@ -96,11 +101,11 @@ const makePgStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
)
const toRow = (e: PM) => {
- const newE = makeETag(e)
+ const newE = makeETag(codec.encode(e))
const id = newE[idKey] as string
const { _etag, [idKey]: _id, ...rest } = newE as any
const data = JSON.stringify(rest)
- return { id, _etag: newE._etag!, data, item: newE }
+ return { id, _etag: newE._etag!, data, item: codec.decode(newE) }
}
const exec = (query: string, params?: readonly unknown[]) =>
@@ -193,7 +198,9 @@ const makePgStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
const sqlText = `SELECT id, _etag, data FROM "${tableName}" WHERE _namespace = $1`
return exec(sqlText, [ns])
.pipe(
- Effect.map((rows) => (rows as any[]).map((r) => parseRow(r, idKey, defaultValues))),
+ Effect.map((rows) =>
+ (rows as any[]).map((r) => parseRow(r, idKey, defaultValues, codec.decode))
+ ),
annotateDb({
operation: "all",
system: "postgresql",
@@ -215,7 +222,7 @@ const makePgStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
Effect.map((rows) => {
const row = (rows as any[])[0]
return row
- ? Option.some(parseRow(row, idKey, defaultValues))
+ ? Option.some(parseRow(row, idKey, defaultValues, codec.decode))
: Option.none()
}),
annotateDb({
@@ -286,7 +293,9 @@ const makePgStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
} as M
})
}
- return (rows as any[]).map((r) => parseRow(r, idKey, defaultValues) as any as M)
+ return (rows as any[]).map((r) =>
+ parseRow(r, idKey, defaultValues, codec.decode) as any as M
+ )
})
)
),
diff --git a/packages/infra/src/Store/SQL/query.ts b/packages/infra/src/Store/SQL/query.ts
index f6790152f..5bb044bf6 100644
--- a/packages/infra/src/Store/SQL/query.ts
+++ b/packages/infra/src/Store/SQL/query.ts
@@ -6,6 +6,7 @@ import type { AggregateIrExpression, ComputedProjectionIrExpression, ComputedPro
import { assertUnreachable } from "effect-app/utils"
import { InfraLogger } from "../../logger.ts"
import { isRelationCheck } from "../codeFilter.ts"
+import { jsonifyFilter, toJsonQueryValue } from "../utils.ts"
export interface SQLDialect {
readonly jsonExtract: (path: string) => string
@@ -177,6 +178,8 @@ export function buildWhereSQLQuery(
limit?: number,
namespace?: string
) {
+ filter = jsonifyFilter(filter)
+ defaultValues = toJsonQueryValue(defaultValues) as Record
const params: unknown[] = []
let paramIndex = 1
@@ -214,7 +217,7 @@ export function buildWhereSQLQuery(
switch (x.op) {
case "in": {
- const vals = x.value as unknown as readonly unknown[]
+ const vals = x.value as readonly unknown[]
const hasNull = vals.some((v) => v == null)
const nonNullVals = vals.filter((v) => v != null)
const parts: string[] = []
@@ -226,7 +229,7 @@ export function buildWhereSQLQuery(
return parts.length > 1 ? `(${parts.join(" OR ")})` : parts[0] ?? "1=0"
}
case "notIn": {
- const vals = x.value as unknown as readonly unknown[]
+ const vals = x.value as readonly unknown[]
const hasNull = vals.some((v) => v == null)
const nonNullVals = vals.filter((v) => v != null)
const parts: string[] = []
@@ -251,26 +254,26 @@ export function buildWhereSQLQuery(
case "includes-any": {
const arrPath = dottedToJsonPath(resolvedPath)
- const vals = x.value as unknown as readonly unknown[]
+ const vals = x.value as readonly unknown[]
const placeholders = vals.map((v) => addParam(dialect.serializeJsonValue(v)))
return dialect.jsonArrayContainsAny(arrPath, placeholders)
}
case "notIncludes-any": {
const arrPath = dottedToJsonPath(resolvedPath)
- const vals = x.value as unknown as readonly unknown[]
+ const vals = x.value as readonly unknown[]
const placeholders = vals.map((v) => addParam(dialect.serializeJsonValue(v)))
return dialect.jsonArrayNotContainsAny(arrPath, placeholders)
}
case "includes-all": {
const arrPath = dottedToJsonPath(resolvedPath)
- const vals = x.value as unknown as readonly unknown[]
+ const vals = x.value as readonly unknown[]
const placeholders = vals.map((v) => addParam(dialect.serializeJsonValue(v)))
return dialect.jsonArrayContainsAll(arrPath, placeholders)
}
case "notIncludes-all": {
const arrPath = dottedToJsonPath(resolvedPath)
- const vals = x.value as unknown as readonly unknown[]
+ const vals = x.value as readonly unknown[]
const placeholders = vals.map((v) => addParam(dialect.serializeJsonValue(v)))
return dialect.jsonArrayNotContainsAll(arrPath, placeholders)
}
diff --git a/packages/infra/src/Store/codeFilter.ts b/packages/infra/src/Store/codeFilter.ts
index 0b270f544..8a04fb5fe 100644
--- a/packages/infra/src/Store/codeFilter.ts
+++ b/packages/infra/src/Store/codeFilter.ts
@@ -6,54 +6,55 @@ import type { FieldValues } from "effect-app/Model/filter/types"
import * as Option from "effect-app/Option"
import type { Filter } from "effect-app/Store"
import { assertUnreachable } from "effect-app/utils"
-import { compare, get, greaterThan, greaterThanExclusive, lowerThan, lowerThanExclusive } from "./utils.ts"
+import { compare, get, greaterThan, greaterThanExclusive, lowerThan, lowerThanExclusive, toJsonQueryValue } from "./utils.ts"
-const vAsArr = (v: string) => v as unknown as any[]
+const vAsArr = (v: unknown) => toJsonQueryValue(v) as any[]
const filterStatement = (x: any, p: FilterR) => {
- const k = get(x, p.path)
+ const k = toJsonQueryValue(get(x, p.path))
+ const v = toJsonQueryValue(p.value)
switch (p.op) {
case "in":
- return p.value.includes(k)
+ return (v as unknown[]).includes(k)
case "notIn":
- return !p.value.includes(k)
+ return !(v as unknown[]).includes(k)
case "lt":
- return lowerThan(k, p.value)
+ return lowerThan(k as any, v as any)
case "lte":
- return lowerThanExclusive(k, p.value)
+ return lowerThanExclusive(k as any, v as any)
case "gt":
- return greaterThan(k, p.value)
+ return greaterThan(k as any, v as any)
case "gte":
- return greaterThanExclusive(k, p.value)
+ return greaterThanExclusive(k as any, v as any)
case "includes":
- return (k as Array).includes(p.value)
+ return (k as Array).includes(v)
case "notIncludes":
- return !(k as Array).includes(p.value)
+ return !(k as Array).includes(v)
case "includes-any":
- return (vAsArr(p.value)).some((_) => (k as Array)?.includes(_))
+ return (vAsArr(p.value)).some((_) => (k as Array)?.includes(_))
case "notIncludes-any":
- return !(vAsArr(p.value)).some((_) => (k as Array)?.includes(_))
+ return !(vAsArr(p.value)).some((_) => (k as Array)?.includes(_))
case "includes-all":
- return (vAsArr(p.value)).every((_) => (k as Array)?.includes(_))
+ return (vAsArr(p.value)).every((_) => (k as Array)?.includes(_))
case "notIncludes-all":
- return !(vAsArr(p.value)).every((_) => (k as Array)?.includes(_))
+ return !(vAsArr(p.value)).every((_) => (k as Array)?.includes(_))
case "contains":
- return (k as string).toLowerCase().includes(p.value.toLowerCase())
+ return (k as string).toLowerCase().includes((v as string).toLowerCase())
case "endsWith":
- return (k as string).toLowerCase().endsWith(p.value.toLowerCase())
+ return (k as string).toLowerCase().endsWith((v as string).toLowerCase())
case "startsWith":
- return (k as string).toLowerCase().startsWith(p.value.toLowerCase())
+ return (k as string).toLowerCase().startsWith((v as string).toLowerCase())
case "notContains":
- return !(k as string).toLowerCase().includes(p.value.toLowerCase())
+ return !(k as string).toLowerCase().includes((v as string).toLowerCase())
case "notEndsWith":
- return !(k as string).toLowerCase().endsWith(p.value.toLowerCase())
+ return !(k as string).toLowerCase().endsWith((v as string).toLowerCase())
case "notStartsWith":
- return !(k as string).toLowerCase().startsWith(p.value.toLowerCase())
+ return !(k as string).toLowerCase().startsWith((v as string).toLowerCase())
case "neq":
- return !compare(k, p.value)
+ return !compare(k, v)
case "eq":
case undefined:
- return compare(k, p.value)
+ return compare(k, v)
default: {
return assertUnreachable(p.op)
}
diff --git a/packages/infra/src/Store/jsonDocument.ts b/packages/infra/src/Store/jsonDocument.ts
new file mode 100644
index 000000000..d0302208b
--- /dev/null
+++ b/packages/infra/src/Store/jsonDocument.ts
@@ -0,0 +1,43 @@
+import type { FieldValues } from "effect-app/Model/filter/types"
+import * as S from "effect-app/Schema"
+import type { PersistenceModelType } from "effect-app/Store"
+import { toJsonQueryValue } from "./utils.ts"
+
+export interface JsonDocumentCodec {
+ readonly encode: (doc: PersistenceModelType) => PersistenceModelType
+ readonly decode: (doc: PersistenceModelType) => PersistenceModelType
+}
+
+const splitEtag = (doc: PersistenceModelType) => {
+ const { _etag, ...rest } = doc
+ return { rest: rest as E, _etag }
+}
+
+const joinEtag = (
+ rest: E,
+ _etag: string | undefined
+): PersistenceModelType => (_etag === undefined ? rest : { ...rest, _etag })
+
+/**
+ * Encoded document ↔ JSON document. Prefer `Schema.toCodecJson(toEncoded(schema))`
+ * when the store has a schema; otherwise lower Date/Map/Set structurally.
+ */
+export const makeJsonDocumentCodec = (schema?: S.Top): JsonDocumentCodec => {
+ if (schema) {
+ const codec = S.toCodecJson(S.toEncoded(schema)) as S.Codec
+ return {
+ encode: (doc) => {
+ const { rest, _etag } = splitEtag(doc)
+ return joinEtag(S.encodeSync(codec)(rest) as E, _etag)
+ },
+ decode: (doc) => {
+ const { rest, _etag } = splitEtag(doc)
+ return joinEtag(S.decodeSync(codec)(rest as S.Json), _etag)
+ }
+ }
+ }
+ return {
+ encode: (doc) => toJsonQueryValue(doc) as PersistenceModelType,
+ decode: (doc) => doc
+ }
+}
diff --git a/packages/infra/src/Store/utils.ts b/packages/infra/src/Store/utils.ts
index f1505adef..0204ccb39 100644
--- a/packages/infra/src/Store/utils.ts
+++ b/packages/infra/src/Store/utils.ts
@@ -1,9 +1,56 @@
import crypto from "crypto"
import * as Effect from "effect-app/Effect"
+import type { FilterResult } from "effect-app/Model/filter/filterApi"
import * as Option from "effect-app/Option"
+import * as S from "effect-app/Schema"
import type { PersistenceModelType, SupportedValues2 } from "effect-app/Store"
import { OptimisticConcurrencyException } from "../errors.ts"
+const dateJson = S.toCodecJson(S.Date)
+
+/**
+ * Lower Date / Map / Set query and document values to JSON, matching
+ * `Schema.toCodecJson` of those declarations so document-DB adapters can bind
+ * native Encoded values as JSON parameters.
+ */
+export function toJsonQueryValue(value: unknown): unknown {
+ if (value instanceof Date) {
+ return S.encodeSync(dateJson)(value)
+ }
+ if (value instanceof Map) {
+ return [...value.entries()].map(([k, v]) => [toJsonQueryValue(k), toJsonQueryValue(v)])
+ }
+ if (value instanceof Set) {
+ return [...value].map(toJsonQueryValue)
+ }
+ if (Array.isArray(value)) {
+ return value.map(toJsonQueryValue)
+ }
+ if (value !== null && typeof value === "object") {
+ const proto = Object.getPrototypeOf(value)
+ if (proto === Object.prototype || proto === null) {
+ const out: Record = {}
+ for (const [k, v] of Object.entries(value)) {
+ out[k] = toJsonQueryValue(v)
+ }
+ return out
+ }
+ const toJSON = (value as { toJSON?: () => unknown }).toJSON
+ if (typeof toJSON === "function") {
+ return toJsonQueryValue(toJSON.call(value))
+ }
+ }
+ return value
+}
+
+export function jsonifyFilter(filter: readonly FilterResult[]): FilterResult[] {
+ return filter.map((r) =>
+ r.t === "and-scope" || r.t === "or-scope" || r.t === "where-scope"
+ ? { ...r, result: jsonifyFilter(r.result) }
+ : { ...r, value: toJsonQueryValue(r.value) }
+ )
+}
+
/** Traverse an object by a dot-separated path string, e.g. `"a.b.c"`. */
export function get(obj: any, path: string): any {
return path.split(".").reduce((res: any, key: string) => (res != null ? res[key] : res), obj)
@@ -55,21 +102,21 @@ export function lowercaseIfString(val: T) {
}
export function compare(valA: unknown, valB: unknown) {
- return valA === valB
+ return toJsonQueryValue(valA) === toJsonQueryValue(valB)
}
export function lowerThan(valA: SupportedValues2, valB: SupportedValues2) {
- return valA < valB
+ return (toJsonQueryValue(valA) as SupportedValues2) < (toJsonQueryValue(valB) as SupportedValues2)
}
export function lowerThanExclusive(valA: SupportedValues2, valB: SupportedValues2) {
- return valA <= valB
+ return (toJsonQueryValue(valA) as SupportedValues2) <= (toJsonQueryValue(valB) as SupportedValues2)
}
export function greaterThan(valA: SupportedValues2, valB: SupportedValues2) {
- return valA > valB
+ return (toJsonQueryValue(valA) as SupportedValues2) > (toJsonQueryValue(valB) as SupportedValues2)
}
export function greaterThanExclusive(valA: SupportedValues2, valB: SupportedValues2) {
- return valA >= valB
+ return (toJsonQueryValue(valA) as SupportedValues2) >= (toJsonQueryValue(valB) as SupportedValues2)
}
diff --git a/packages/infra/test/cosmos-query.test.ts b/packages/infra/test/cosmos-query.test.ts
index c272d382d..7c4535322 100644
--- a/packages/infra/test/cosmos-query.test.ts
+++ b/packages/infra/test/cosmos-query.test.ts
@@ -13,6 +13,65 @@ type OrderEnc = S.Codec.Encoded
// Length projection via `relation(...).length()` should emit a scalar
// ARRAY_LENGTH expression rather than pulling (or reshaping) the array.
+describe("cosmos query filter: native Encoded values", () => {
+ it("binds Date as ISO string parameters", () => {
+ const result = buildWhereCosmosQuery3(
+ "id",
+ [{ t: "where", path: "n", op: "eq", value: new Date("2024-01-01T00:00:00.000Z") }],
+ "Orders",
+ {}
+ )
+ expect(result.parameters).toEqual(
+ expect.arrayContaining([{ name: "@v0", value: "2024-01-01T00:00:00.000Z" }])
+ )
+ })
+
+ it("binds Map as array of tuples", () => {
+ const result = buildWhereCosmosQuery3(
+ "id",
+ [{ t: "where", path: "meta", op: "eq", value: new Map([["k", "v"]]) }],
+ "Orders",
+ {}
+ )
+ expect(result.parameters).toEqual(
+ expect.arrayContaining([{ name: "@v0", value: [["k", "v"]] }])
+ )
+ })
+
+ it("binds includes Date as ISO string", () => {
+ const result = buildWhereCosmosQuery3(
+ "id",
+ [{ t: "where", path: "dates", op: "includes", value: new Date("2024-01-01T00:00:00.000Z") }],
+ "Orders",
+ {}
+ )
+ expect(result.query).toContain("ARRAY_CONTAINS")
+ expect(result.parameters).toEqual(
+ expect.arrayContaining([{ name: "@v0", value: "2024-01-01T00:00:00.000Z" }])
+ )
+ })
+
+ it("binds includes-any Date Set as ISO parameters", () => {
+ const result = buildWhereCosmosQuery3(
+ "id",
+ [{
+ t: "where",
+ path: "dates",
+ op: "includes-any",
+ value: new Set([new Date("2024-01-01T00:00:00.000Z")])
+ }],
+ "Orders",
+ {}
+ )
+ expect(result.parameters).toEqual(
+ expect.arrayContaining([
+ { name: "@v0", value: ["2024-01-01T00:00:00.000Z"] },
+ { name: "@v0__0", value: "2024-01-01T00:00:00.000Z" }
+ ])
+ )
+ })
+})
+
describe("cosmos query projection: array length", () => {
it("projects packages length via ARRAY_LENGTH", () => {
const q = make().pipe(
@@ -29,13 +88,13 @@ describe("cosmos query projection: array length", () => {
ir.filter ?? [],
"Orders",
{},
- ir.select as any
+ ir.select
)
expect(result.query).toMatch(/ARRAY_LENGTH\(f(?:\.packages|\["packages"\])\)/)
expect(result.query).toContain("AS packageCount")
// Must not pull the full array nor reshape via subquery
- expect(result.query).not.toMatch(/ARRAY\s*\(\s*SELECT[^)]*FROM\s+t\s+in\s+f[\.\["]/i)
+ expect(result.query).not.toMatch(/ARRAY\s*\(\s*SELECT[^)]*FROM\s+t\s+in\s+f[.["]/i)
expect(result.query).not.toMatch(/SELECT VALUE COUNT/)
})
})
@@ -83,7 +142,7 @@ describe("cosmos query projection: union array fields", () => {
const packageSelects = select.filter((item) =>
typeof item === "object" && item !== null && "key" in item && item.key === "packages"
)
- const result = buildWhereCosmosQuery3("id", ir.filter ?? [], "Orders", {}, ir.select as any)
+ const result = buildWhereCosmosQuery3("id", ir.filter ?? [], "Orders", {}, ir.select)
expect(packageSelects).toHaveLength(1)
expect(result.query.match(/\bAS\s+packages\b/g) ?? []).toHaveLength(1)
@@ -102,7 +161,7 @@ describe("cosmos query projection: union array fields", () => {
const packageSelects = select.filter((item) =>
typeof item === "object" && item !== null && "key" in item && item.key === "packages"
)
- const result = buildWhereCosmosQuery3("id", ir.filter ?? [], "Orders", {}, ir.select as any)
+ const result = buildWhereCosmosQuery3("id", ir.filter ?? [], "Orders", {}, ir.select)
expect(packageSelects).toHaveLength(1)
expect(result.query.match(/\bAS\s+packages\b/g) ?? []).toHaveLength(1)
@@ -146,7 +205,7 @@ describe("cosmos query projection: relation-every parameter binding", () => {
)
const ir = toFilter(q as any, DN as any)
- const result = buildWhereCosmosQuery3("id", ir.filter ?? [], "DN", {}, ir.select as any)
+ const result = buildWhereCosmosQuery3("id", ir.filter ?? [], "DN", {}, ir.select)
// Each filter element binds exactly one parameter: 2 every filters + 2 main filter = 4.
expect(result.parameters).toHaveLength(4)
diff --git a/packages/infra/test/query.test.ts b/packages/infra/test/query.test.ts
index 680c7e5f1..f5cdc07f9 100644
--- a/packages/infra/test/query.test.ts
+++ b/packages/infra/test/query.test.ts
@@ -11,10 +11,15 @@ import * as Option from "effect-app/Option"
import * as S from "effect-app/Schema"
import { setupRequestContextFromCurrent } from "effect-app/setupRequest"
import { flow, pipe } from "effect/Function"
+import * as Redacted from "effect/Redacted"
import * as SchemaTransformation from "effect/SchemaTransformation"
import * as Struct from "effect/Struct"
+import * as fs from "fs"
+import * as os from "os"
+import * as path from "path"
import { inspect } from "util"
import { expect, expectTypeOf, it } from "vitest"
+import { DiskStoreLayer } from "../src/Store/Disk.js"
import { memFilter, MemoryStoreLive } from "../src/Store/Memory.js"
import { SomeService } from "./fixtures.js"
@@ -41,7 +46,7 @@ const q = make()
where("displayName", "Verona"),
or(
where("displayName", "Riley"),
- and("n", "gt", "2021-01-01T00:00:00Z") // TODO: work with To type translation, so Date?
+ and("n", "gt", new Date("2021-01-01T00:00:00Z"))
),
order("displayName"),
page({ take: 10 }),
@@ -141,7 +146,7 @@ it("works with repo", () =>
where("displayName", "Verona"),
or(
where("displayName", "Riley"),
- and("n", "gt", "2021-01-01T00:00:00Z") // TODO: work with To type translation, so Date?
+ and("n", "gt", new Date("2021-01-01T00:00:00Z"))
),
order("displayName"),
page({ take: 10 }),
@@ -168,6 +173,11 @@ it("works with repo", () =>
expect(q1).toEqual(items.slice(0, 2).toReversed().map(Struct.pick(["id", "displayName"])))
expect(q2).toEqual(items.slice(0, 2).toReversed().map(Struct.pick(["displayName"])))
+
+ const byDate = yield* somethingRepo.query(
+ where("n", new Date("2020-01-01T00:00:00.000Z"))
+ )
+ expect(byDate.map((_) => _.displayName)).toEqual(["Verona", "Riley"])
})
.pipe(
Effect.provide(Layer.mergeAll(SomethingRepo.Test, SomeService.Default)),
@@ -176,6 +186,86 @@ it("works with repo", () =>
Effect.runPromise
))
+it("memory store round-trips Date/Set/Map via JSON codecs", () =>
+ Effect
+ .gen(function*() {
+ class Doc extends S.Class("JsonCodecDoc")({
+ id: S.String,
+ at: S.Date,
+ tags: S.ReadonlySet(S.String),
+ meta: S.ReadonlyMap({ key: S.String, value: S.Finite })
+ }) {}
+ const at = new Date("2024-06-01T00:00:00.000Z")
+ const saved = new Doc({
+ id: "d1",
+ at,
+ tags: new Set(["a", "b"]),
+ meta: new Map([["n", 1]])
+ })
+ const repo = yield* makeRepo("JsonCodecDoc", Doc, { makeInitial: Effect.succeed([saved]) })
+ const found = yield* repo.find("d1")
+ expect(Option.isSome(found)).toBe(true)
+ if (Option.isSome(found)) {
+ expect(found.value.at.toISOString()).toBe(at.toISOString())
+ expect(found.value.tags).toEqual(new Set(["a", "b"]))
+ expect(found.value.meta).toEqual(new Map([["n", 1]]))
+ }
+ const byDate = yield* repo.query(where("at", at))
+ expect(byDate.map((_) => _.id)).toEqual(["d1"])
+ const byTag = yield* repo.query(where("tags", "includes", "b"))
+ expect(byTag.map((_) => _.id)).toEqual(["d1"])
+ })
+ .pipe(Effect.provide(TestStoreLive), setupRequestContextFromCurrent(), Effect.scoped, Effect.runPromise))
+
+it("disk store round-trips Date/Set/Map via JSON codecs", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "effect-app-disk-json-"))
+ const diskLive = Layer.merge(
+ DiskStoreLayer({ url: Redacted.make(`disk://${dir}`), prefix: "", dbName: "test" }, dir),
+ RepositoryRegistryLive
+ )
+ return Effect
+ .gen(function*() {
+ class Doc extends S.Class("JsonCodecDiskDoc")({
+ id: S.String,
+ at: S.Date,
+ tags: S.ReadonlySet(S.String),
+ meta: S.ReadonlyMap({ key: S.String, value: S.Finite })
+ }) {}
+ const at = new Date("2024-06-01T00:00:00.000Z")
+ const saved = new Doc({
+ id: "d1",
+ at,
+ tags: new Set(["a", "b"]),
+ meta: new Map([["n", 1]])
+ })
+ const repo = yield* makeRepo("JsonCodecDiskDoc", Doc, { makeInitial: Effect.succeed([saved]) })
+ const found = yield* repo.find("d1")
+ expect(Option.isSome(found)).toBe(true)
+ if (Option.isSome(found)) {
+ expect(found.value.at.toISOString()).toBe(at.toISOString())
+ expect(found.value.tags).toEqual(new Set(["a", "b"]))
+ expect(found.value.meta).toEqual(new Map([["n", 1]]))
+ }
+ const jsonFile = fs.readdirSync(dir).find((f) => f.endsWith(".json"))
+ expect(jsonFile).toBeDefined()
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, jsonFile!), "utf8")) as Array<{
+ at: unknown
+ tags: unknown
+ meta: unknown
+ }>
+ expect(raw[0]?.at).toBe(at.toISOString())
+ expect(raw[0]?.tags).toEqual(["a", "b"])
+ expect(raw[0]?.meta).toEqual([["n", 1]])
+ })
+ .pipe(
+ Effect.provide(diskLive),
+ setupRequestContextFromCurrent(),
+ Effect.scoped,
+ Effect.runPromise
+ )
+ .finally(() => fs.rmSync(dir, { recursive: true, force: true }))
+})
+
it("collect", () =>
Effect
.gen(function*() {
@@ -196,8 +286,8 @@ it("collect", () =>
})),
S.toType(S.Option(S.String)),
(_) =>
- _.displayName === "Riley" && _.n === "2020-01-01T00:00:00.000Z"
- ? Option.some(`${_.displayName}-${_.n}`)
+ _.displayName === "Riley" && _.n.toISOString() === "2020-01-01T00:00:00.000Z"
+ ? Option.some(`${_.displayName}-${_.n.toISOString()}`)
: Option.none()
),
"collect"
@@ -215,7 +305,7 @@ it("collect", () =>
QueryEnd<{
readonly id: string
readonly displayName: string
- readonly n: string
+ readonly n: Date
readonly union: {
readonly _tag: "string"
readonly value: string
@@ -530,7 +620,7 @@ it(
const schema = S.Struct({
id: S.String,
createdAt: S.Date.pipe(
- S.withDecodingDefault(Effect.sync(() => new Date().toISOString())),
+ S.withDecodingDefault(Effect.sync(() => new Date())),
S.withConstructorDefault(Effect.sync(() => new Date()))
)
})
@@ -543,7 +633,7 @@ it(
const outputSchema = S.Struct({
id: S.Literal("123"),
createdAt: S.Date.pipe(
- S.withDecodingDefault(Effect.sync(() => new Date().toISOString())),
+ S.withDecodingDefault(Effect.sync(() => new Date())),
S.withConstructorDefault(Effect.sync(() => new Date()))
)
})
@@ -824,7 +914,7 @@ it("ProjectableFromDomain distributes over tagged union Encoded", () => {
type GoodCheck = ProjectableFromDomain
type BadCheck = ProjectableFromDomain
- const _good: GoodCheck = undefined as unknown
+ const _good: GoodCheck = undefined
// @ts-expect-error cancelled branch requires activeRequest not present on domain cancelled
const _bad: BadCheck = undefined as unknown
void _good
@@ -852,7 +942,7 @@ it("ProjectableFromDomain allows dual same-tag domain variants", () => {
type GoodCheck = ProjectableFromDomain
type BadFlatCheck = ProjectableFromDomain
- const _good: GoodCheck = undefined as unknown
+ const _good: GoodCheck = undefined
// @ts-expect-error packages is not on domain initial; multi-tag flat intersection rejects it
const _badFlat: BadFlatCheck = undefined as unknown
void _good
@@ -1345,6 +1435,27 @@ it("does not allow string queries on arrays", () =>
expectTypeOf(good2).toEqualTypeOf>()
expectTypeOf(good3).toEqualTypeOf>()
expectTypeOf(good4).toEqualTypeOf>()
+
+ type Native = {
+ readonly id: string
+ readonly dates: Date[]
+ readonly dateSet: ReadonlySet
+ readonly tags: ReadonlySet
+ }
+ const native = make()
+ const d = new Date("2020-01-01T00:00:00.000Z")
+ const n1 = native.pipe(where("dates", "includes", d))
+ const n2 = native.pipe(where("dateSet", "includes", d))
+ const n3 = native.pipe(where("tags", "includes", "a"))
+ const n4 = native.pipe(where("dates", "includes-any", [d]))
+ const n5 = native.pipe(where("dateSet", "includes-any", new Set([d])))
+ const n6 = native.pipe(where("id", "in", new Set(["x"])))
+ expectTypeOf(n1).toEqualTypeOf>()
+ expectTypeOf(n2).toEqualTypeOf>()
+ expectTypeOf(n3).toEqualTypeOf>()
+ expectTypeOf(n4).toEqualTypeOf>()
+ expectTypeOf(n5).toEqualTypeOf>()
+ expectTypeOf(n6).toEqualTypeOf>()
})
.pipe(Effect.provide(TestStoreLive), setupRequestContextFromCurrent(), Effect.scoped, Effect.runPromise))
@@ -2064,6 +2175,31 @@ it("codeFilter: array includes / includes-any / includes-all", () => {
expect(runCF(make().pipe(where("tags", "includes-all", ["red", "blue"])))).toEqual(["3"])
})
+it("codeFilter: Date array / Set includes and in", () => {
+ const d0 = new Date("2020-01-01T00:00:00.000Z")
+ const d1 = new Date("2021-01-01T00:00:00.000Z")
+ type DateRow = {
+ readonly id: string
+ readonly dates: Date[]
+ readonly dateSet: ReadonlySet
+ readonly tag: string
+ }
+ const rows: DateRow[] = [
+ { id: "1", dates: [d0], dateSet: new Set([d0]), tag: "a" },
+ { id: "2", dates: [d1, d0], dateSet: new Set([d1]), tag: "b" }
+ ]
+ const run = (q: any) => (memFilter(toFilter(q))(rows) as DateRow[]).map((_) => _.id)
+ expect(run(make().pipe(where("dates", "includes", d0))).sort()).toEqual(["1", "2"])
+ expect(run(make().pipe(where("dates", "includes", d1)))).toEqual(["2"])
+ expect(run(make().pipe(where("dateSet", "includes", d0)))).toEqual(["1"])
+ expect(run(make().pipe(where("dates", "includes-any", [d1])))).toEqual(["2"])
+ expect(run(make().pipe(where("dateSet", "includes-any", new Set([d0, d1])))).sort()).toEqual([
+ "1",
+ "2"
+ ])
+ expect(run(make().pipe(where("tag", "in", new Set(["a"]))))).toEqual(["1"])
+})
+
it("codeFilter: in / notIn", () => {
expect(runCF(make().pipe(where("tag", "in", ["x", "z"]))).sort()).toEqual(["1", "3"])
expect(runCF(make().pipe(where("tag", "notIn", ["x", "z"]))).sort()).toEqual(["2", "4"])
@@ -2129,7 +2265,7 @@ it("memFilter: agg-count-when groups rows and counts conditionally", () => {
},
{ key: "total", aggregate: { _tag: "agg-count" } }
] as any
- })(rows as any) as any[]
+ })(rows) as any[]
expect(result.length).toBe(2)
const nyc = result.find((r: any) => r.city === "NYC")!
@@ -2155,7 +2291,7 @@ it("memFilter: agg-sum / agg-min / agg-max aggregate numerics", () => {
{ key: "min", aggregate: { _tag: "agg-min", field: "salary" } },
{ key: "max", aggregate: { _tag: "agg-max", field: "salary" } }
] as any
- })(rows as any) as any[]
+ })(rows) as any[]
expect(result.length).toBe(2)
const eng = result.find((r: any) => r.dept === "eng")!
@@ -2179,7 +2315,7 @@ it("memFilter: aggregate with nested path grouping", () => {
{ key: "city", path: "address.city" },
{ key: "count", aggregate: { _tag: "agg-count" } }
] as any
- })(rows as any) as any[]
+ })(rows) as any[]
expect(result.length).toBe(2)
expect(result.find((r: any) => r.city === "NYC")!.count).toBe(2)
diff --git a/packages/infra/test/sql-store.test.ts b/packages/infra/test/sql-store.test.ts
index 433f7373e..59adc1c25 100644
--- a/packages/infra/test/sql-store.test.ts
+++ b/packages/infra/test/sql-store.test.ts
@@ -24,11 +24,62 @@ describe("SQL query builder (SQLite dialect)", () => {
expect(result.params).toContain("John")
})
+ it("where eq Date binds ISO string", () => {
+ const result = buildWhereSQLQuery(
+ sqliteDialect,
+ "id",
+ [{ t: "where", path: "n", op: "eq", value: new Date("2024-01-01T00:00:00.000Z") }],
+ "users",
+ {}
+ )
+ expect(result.params).toContain("2024-01-01T00:00:00.000Z")
+ })
+
+ it("where in Set binds array values", () => {
+ const result = buildWhereSQLQuery(
+ sqliteDialect,
+ "id",
+ [{ t: "where", path: "tags", op: "in", value: new Set(["a", "b"]) }],
+ "users",
+ {}
+ )
+ expect(result.params).toEqual(expect.arrayContaining(["a", "b"]))
+ })
+
+ it("where includes Date binds ISO string", () => {
+ const result = buildWhereSQLQuery(
+ sqliteDialect,
+ "id",
+ [{ t: "where", path: "dates", op: "includes", value: new Date("2024-01-01T00:00:00.000Z") }],
+ "users",
+ {}
+ )
+ expect(result.params).toContain("2024-01-01T00:00:00.000Z")
+ })
+
+ it("where includes-any Date[] binds ISO strings", () => {
+ const result = buildWhereSQLQuery(
+ sqliteDialect,
+ "id",
+ [{
+ t: "where",
+ path: "dates",
+ op: "includes-any",
+ value: [new Date("2024-01-01T00:00:00.000Z"), new Date("2024-06-01T00:00:00.000Z")]
+ }],
+ "users",
+ {}
+ )
+ expect(result.params).toEqual(
+ expect.arrayContaining(["2024-01-01T00:00:00.000Z", "2024-06-01T00:00:00.000Z"])
+ )
+ })
+
it("where eq number", () => {
const result = buildWhereSQLQuery(
sqliteDialect,
"id",
- [{ t: "where", path: "age", op: "eq", value: 25 as any }],
+ [{ t: "where", path: "age", op: "eq", value: 25 }],
"users",
{}
)
@@ -40,7 +91,7 @@ describe("SQL query builder (SQLite dialect)", () => {
const result = buildWhereSQLQuery(
sqliteDialect,
"id",
- [{ t: "where", path: "age", op: "gt", value: 18 as any }],
+ [{ t: "where", path: "age", op: "gt", value: 18 }],
"users",
{}
)
@@ -70,7 +121,7 @@ describe("SQL query builder (SQLite dialect)", () => {
"id",
[
{ t: "where", path: "name", op: "eq", value: "Alice" },
- { t: "and", path: "age", op: "gt", value: 18 as any }
+ { t: "and", path: "age", op: "gt", value: 18 }
],
"users",
{}
@@ -83,7 +134,7 @@ describe("SQL query builder (SQLite dialect)", () => {
const result = buildWhereSQLQuery(
sqliteDialect,
"id",
- [{ t: "where", path: "id", op: "in", value: ["a", "b", "c"] as any }],
+ [{ t: "where", path: "id", op: "in", value: ["a", "b", "c"] }],
"users",
{}
)
@@ -167,7 +218,7 @@ describe("SQL query builder (SQLite dialect)", () => {
const result = buildWhereSQLQuery(
sqliteDialect,
"id",
- [{ t: "where", path: "tags", op: "includes-any", value: ["admin", "user"] as any }],
+ [{ t: "where", path: "tags", op: "includes-any", value: ["admin", "user"] }],
"users",
{}
)
@@ -495,7 +546,7 @@ describe("SQL query builder (PostgreSQL dialect)", () => {
const result = buildWhereSQLQuery(
pgDialect,
"id",
- [{ t: "where", path: "status", op: "in", value: ["active", "pending"] as any }],
+ [{ t: "where", path: "status", op: "in", value: ["active", "pending"] }],
"users",
{}
)
@@ -787,7 +838,7 @@ describe("SQL Store (SQLite integration)", () => {
)
const r1 = query(db, q1.sql, q1.params)
expect(r1.length).toBe(1)
- expect((r1[0] as any).id).toBe("1")
+ expect(r1[0].id).toBe("1")
const q2 = buildWhereSQLQuery(
sqliteDialect,
@@ -798,13 +849,13 @@ describe("SQL Store (SQLite integration)", () => {
)
const r2 = query(db, q2.sql, q2.params)
expect(r2.length).toBe(1)
- expect((r2[0] as any).id).toBe("2")
+ expect(r2[0].id).toBe("2")
// Both queryable by id column
const q3 = buildWhereSQLQuery(
sqliteDialect,
"id",
- [{ t: "where", path: "id", op: "in", value: ["1", "2"] as any }],
+ [{ t: "where", path: "id", op: "in", value: ["1", "2"] }],
"test_compat",
{}
)
@@ -830,7 +881,7 @@ describe("SQL Store (SQLite integration)", () => {
const q1 = buildWhereSQLQuery(
sqliteDialect,
"id",
- [{ t: "where", path: "age", op: "gt", value: 28 as any }],
+ [{ t: "where", path: "age", op: "gt", value: 28 }],
"test_noid",
{}
)
@@ -846,8 +897,8 @@ describe("SQL Store (SQLite integration)", () => {
)
const r2 = query(db, q2.sql, q2.params)
expect(r2.length).toBe(1)
- expect((r2[0] as any).id).toBe("2")
- expect((JSON.parse((r2[0] as any).data) as any).name).toBe("Bob")
+ expect(r2[0].id).toBe("2")
+ expect((JSON.parse(r2[0].data) as any).name).toBe("Bob")
// Order + limit still works
const q3 = buildWhereSQLQuery(
@@ -863,7 +914,7 @@ describe("SQL Store (SQLite integration)", () => {
)
const r3 = query(db, q3.sql, q3.params)
expect(r3.length).toBe(2)
- expect((JSON.parse((r3[0] as any).data) as any).name).toBe("Bob") // youngest first
+ expect((JSON.parse(r3[0].data) as any).name).toBe("Bob") // youngest first
}))
it("query builder generates valid SQL for SQLite", () =>
@@ -896,13 +947,13 @@ describe("SQL Store (SQLite integration)", () => {
{}
)
expect(query(db, q1.sql, q1.params).length).toBe(1)
- expect((JSON.parse((query(db, q1.sql, q1.params)[0] as any).data) as any).name).toBe("Alice")
+ expect((JSON.parse(query(db, q1.sql, q1.params)[0].data) as any).name).toBe("Alice")
// Test gt
const q2 = buildWhereSQLQuery(
sqliteDialect,
"id",
- [{ t: "where", path: "age", op: "gt", value: 28 as any }],
+ [{ t: "where", path: "age", op: "gt", value: 28 }],
"test_people",
{}
)
@@ -927,20 +978,20 @@ describe("SQL Store (SQLite integration)", () => {
"id",
[
{ t: "where", path: "name", op: "eq", value: "Alice" },
- { t: "and", path: "age", op: "gt", value: 25 as any }
+ { t: "and", path: "age", op: "gt", value: 25 }
],
"test_people",
{}
)
const r4 = query(db, q4.sql, q4.params)
expect(r4.length).toBe(1)
- expect((JSON.parse((r4[0] as any).data) as any).name).toBe("Alice")
+ expect((JSON.parse(r4[0].data) as any).name).toBe("Alice")
// Test IN
const q5 = buildWhereSQLQuery(
sqliteDialect,
"id",
- [{ t: "where", path: "id", op: "in", value: ["1", "3"] as any }],
+ [{ t: "where", path: "id", op: "in", value: ["1", "3"] }],
"test_people",
{}
)
@@ -966,7 +1017,7 @@ describe("SQL Store (SQLite integration)", () => {
)
const r7 = query(db, q7.sql, q7.params)
expect(r7.length).toBe(1)
- expect((JSON.parse((r7[0] as any).data) as any).name).toBe("Alice")
+ expect((JSON.parse(r7[0].data) as any).name).toBe("Alice")
// Test includes (array)
const q8 = buildWhereSQLQuery(
@@ -987,7 +1038,7 @@ describe("SQL Store (SQLite integration)", () => {
{
t: "or-scope",
result: [
- { t: "where", path: "age", op: "gt", value: 30 as any },
+ { t: "where", path: "age", op: "gt", value: 30 },
{ t: "and", path: "name", op: "contains", value: "ar" }
],
relation: "some"
@@ -1012,7 +1063,7 @@ describe("SQL Store (SQLite integration)", () => {
)
const r10 = query(db, q10.sql, q10.params)
expect(r10.length).toBe(2)
- expect((JSON.parse((r10[0] as any).data) as any).name).toBe("Charlie") // oldest first
+ expect((JSON.parse(r10[0].data) as any).name).toBe("Charlie") // oldest first
}))
it("computed relation-every / distinct-count / sum / collect run on SQLite", () =>
@@ -1154,7 +1205,7 @@ describe("SQL Store (SQLite integration)", () => {
const results = query(db, nsSql, params)
// Should only get Alice and Bob (primary namespace), not Charlie (other namespace)
expect(results.length).toBe(2)
- const names = results.map((r) => (JSON.parse((r as any).data) as any).name).sort()
+ const names = results.map((r) => (JSON.parse(r.data) as any).name).sort()
expect(names).toEqual(["Alice", "Bob"])
}))
@@ -1285,7 +1336,7 @@ describe("boolean WHERE clauses — query builder", () => {
const result = buildWhereSQLQuery(
sqliteDialect,
"id",
- [{ t: "where", path: "flag", op: "eq", value: true as any }],
+ [{ t: "where", path: "flag", op: "eq", value: true }],
"t",
{}
)
@@ -1297,7 +1348,7 @@ describe("boolean WHERE clauses — query builder", () => {
const result = buildWhereSQLQuery(
sqliteDialect,
"id",
- [{ t: "where", path: "flag", op: "eq", value: false as any }],
+ [{ t: "where", path: "flag", op: "eq", value: false }],
"t",
{}
)
@@ -1308,7 +1359,7 @@ describe("boolean WHERE clauses — query builder", () => {
const r1 = buildWhereSQLQuery(
sqliteDialect,
"id",
- [{ t: "where", path: "flag", op: "neq", value: true as any }],
+ [{ t: "where", path: "flag", op: "neq", value: true }],
"t",
{}
)
@@ -1317,7 +1368,7 @@ describe("boolean WHERE clauses — query builder", () => {
const r2 = buildWhereSQLQuery(
sqliteDialect,
"id",
- [{ t: "where", path: "flag", op: "in", value: [true, false] as any }],
+ [{ t: "where", path: "flag", op: "in", value: [true, false] }],
"t",
{}
)
@@ -1328,7 +1379,7 @@ describe("boolean WHERE clauses — query builder", () => {
const result = buildWhereSQLQuery(
pgDialect,
"id",
- [{ t: "where", path: "flag", op: "eq", value: true as any }],
+ [{ t: "where", path: "flag", op: "eq", value: true }],
"t",
{}
)
@@ -1340,7 +1391,7 @@ describe("boolean WHERE clauses — query builder", () => {
const result = buildWhereSQLQuery(
pgDialect,
"id",
- [{ t: "where", path: "flag", op: "eq", value: false as any }],
+ [{ t: "where", path: "flag", op: "eq", value: false }],
"t",
{}
)
@@ -1351,7 +1402,7 @@ describe("boolean WHERE clauses — query builder", () => {
const result = buildWhereSQLQuery(
pgDialect,
"id",
- [{ t: "where", path: "flag", op: "in", value: [true, false] as any }],
+ [{ t: "where", path: "flag", op: "in", value: [true, false] }],
"t",
{}
)
@@ -1373,7 +1424,7 @@ describe("boolean WHERE clauses — query builder", () => {
const result = buildWhereSQLQuery(
pgDialect,
"id",
- [{ t: "where", path: "age", op: "gt", value: 18 as any }],
+ [{ t: "where", path: "age", op: "gt", value: 18 }],
"t",
{}
)
@@ -1404,13 +1455,13 @@ describe("boolean WHERE clauses — SQLite integration (end-to-end)", () => {
const q = buildWhereSQLQuery(
sqliteDialect,
"id",
- [{ t: "where", path: "flag", op: "eq", value: true as any }],
+ [{ t: "where", path: "flag", op: "eq", value: true }],
"t",
{}
)
const rows = query(db, q.sql, q.params)
expect(rows.length).toBe(1)
- expect((JSON.parse((rows[0] as any).data) as any).name).toBe("Alice")
+ expect((JSON.parse(rows[0].data) as any).name).toBe("Alice")
}))
it("where flag = false matches only false rows", () =>
@@ -1426,13 +1477,13 @@ describe("boolean WHERE clauses — SQLite integration (end-to-end)", () => {
const q = buildWhereSQLQuery(
sqliteDialect,
"id",
- [{ t: "where", path: "flag", op: "eq", value: false as any }],
+ [{ t: "where", path: "flag", op: "eq", value: false }],
"t",
{}
)
const rows = query(db, q.sql, q.params)
expect(rows.length).toBe(1)
- expect((JSON.parse((rows[0] as any).data) as any).name).toBe("Bob")
+ expect((JSON.parse(rows[0].data) as any).name).toBe("Bob")
}))
it("where nested boolean path works", () =>
@@ -1448,13 +1499,13 @@ describe("boolean WHERE clauses — SQLite integration (end-to-end)", () => {
const q = buildWhereSQLQuery(
sqliteDialect,
"id",
- [{ t: "where", path: "meta.active", op: "eq", value: true as any }],
+ [{ t: "where", path: "meta.active", op: "eq", value: true }],
"t",
{}
)
const rows = query(db, q.sql, q.params)
expect(rows.length).toBe(1)
- expect((JSON.parse((rows[0] as any).data) as any).name).toBe("Alice")
+ expect((JSON.parse(rows[0].data) as any).name).toBe("Alice")
}))
it("where neq boolean works", () =>
@@ -1470,13 +1521,13 @@ describe("boolean WHERE clauses — SQLite integration (end-to-end)", () => {
const q = buildWhereSQLQuery(
sqliteDialect,
"id",
- [{ t: "where", path: "flag", op: "neq", value: true as any }],
+ [{ t: "where", path: "flag", op: "neq", value: true }],
"t",
{}
)
const rows = query(db, q.sql, q.params)
expect(rows.length).toBe(1)
- expect((JSON.parse((rows[0] as any).data) as any).name).toBe("Bob")
+ expect((JSON.parse(rows[0].data) as any).name).toBe("Bob")
}))
})
@@ -1573,7 +1624,7 @@ describe("toRow strips _etag and id from data", () => {
const toRow = (e: any, idKey: IdKey) => {
const newE = makeETag(e)
const id = newE[idKey] as string
- const { _etag, [idKey]: _id, ...rest } = newE as any
+ const { _etag, [idKey]: _id, ...rest } = newE
const data = JSON.stringify(rest)
return { id, _etag: newE._etag!, data, item: newE }
}
diff --git a/packages/vue-components/__tests__/OmegaForm/DateValidation.test.ts b/packages/vue-components/__tests__/OmegaForm/DateValidation.test.ts
index 6960e2df7..9d794a84d 100644
--- a/packages/vue-components/__tests__/OmegaForm/DateValidation.test.ts
+++ b/packages/vue-components/__tests__/OmegaForm/DateValidation.test.ts
@@ -41,7 +41,7 @@ describe("Date field validation", () => {
setup() {
const form = useOmegaForm(
S.Struct({
- date: S.Date
+ date: S.DateFromString
}),
{
onSubmit: async ({ value }) => {
diff --git a/packages/vue-components/src/components/OmegaForm/meta/checks.ts b/packages/vue-components/src/components/OmegaForm/meta/checks.ts
index eef6a693f..78aca09f4 100644
--- a/packages/vue-components/src/components/OmegaForm/meta/checks.ts
+++ b/packages/vue-components/src/components/OmegaForm/meta/checks.ts
@@ -93,7 +93,10 @@ export const getFieldMetadataFromAst = (property: S.AST.AST) => {
base.type = "boolean"
} else if (
S.AST.isDeclaration(property)
- && (property.annotations as any)?.typeConstructor?._tag === "Date"
+ && (
+ (property.annotations as any)?.typeConstructor?._tag === "Date"
+ || (property.annotations as any)?.representation?.id === "effect/schema/Date"
+ )
) {
base.type = "date"
} else {
diff --git a/packages/vue-components/stories/OmegaForm/AutoGeneration.vue b/packages/vue-components/stories/OmegaForm/AutoGeneration.vue
index 7071b30fb..ea511b1e2 100644
--- a/packages/vue-components/stories/OmegaForm/AutoGeneration.vue
+++ b/packages/vue-components/stories/OmegaForm/AutoGeneration.vue
@@ -63,7 +63,7 @@ const schema = S.Struct({
boolean: S.Boolean,
email: S.Email,
url: S.Url,
- date: S.Date
+ date: S.DateFromString
})
type Meta = OmegaAutoGenMeta<
typeof schema.Encoded,
diff --git a/packages/vue-components/stories/OmegaForm/Date.vue b/packages/vue-components/stories/OmegaForm/Date.vue
index 3466bf98e..53e7420a5 100644
--- a/packages/vue-components/stories/OmegaForm/Date.vue
+++ b/packages/vue-components/stories/OmegaForm/Date.vue
@@ -26,7 +26,7 @@ import * as S from "effect-app/Schema"
import { useOmegaForm } from "../../src/components/OmegaForm"
const schema = S.Struct({
- date: S.NullOr(S.Date)
+ date: S.NullOr(S.DateFromString)
})
const form = useOmegaForm(schema, {