Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/native-encoded-query-json.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 5 additions & 4 deletions packages/effect-app/src/Model/Repository/internal/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand All @@ -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)
)
Expand All @@ -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)
Expand Down Expand Up @@ -737,7 +737,7 @@ export function makeRepoInternal<
queryRaw<A, Out, QR>(schema: S.Codec<A, Out, QR>, q: Q.RawQuery<Encoded, Out>) {
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)
)
),
Expand Down Expand Up @@ -887,6 +887,7 @@ export function makeStore<Encoded extends FieldValues>() {
: undefined,
{
...config,
schema,
partitionValue: config?.partitionValue
?? ((_) => "primary") /*(isIntegrationEvent(r) ? r.companyId : r.id*/
}
Expand Down
2 changes: 1 addition & 1 deletion packages/effect-app/src/Model/filter/filterApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export type FilterR = {
op: Ops

path: string
value: string // ToDO: Value[]
value: unknown
}

export type FilterResult =
Expand Down
17 changes: 10 additions & 7 deletions packages/effect-app/src/Model/query/dsl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1157,7 +1157,11 @@ export const aggregate: {
return new Project({ current, schema, mode: "aggregate", aggregateMap } as any)
}

type GetArV<T> = T extends readonly (infer R)[] ? R : never
type GetArV<T> = T extends ReadonlySet<infer R> ? R
: T extends readonly (infer R)[] ? R
: never

type InValues<T> = readonly T[] | ReadonlySet<T>

export type FilterContinuations<IsCurrentInitial extends boolean = false> = {
<
Expand Down Expand Up @@ -1207,13 +1211,12 @@ export type FilterContinuations<IsCurrentInitial extends boolean = false> = {
<
TFieldValues extends FieldValues,
TFieldName extends FieldPath<TFieldValues>,
const V extends readonly FieldPathValue<TFieldValues, TFieldName>[],
TFieldValuesRefined extends TFieldValues = TFieldValues,
E extends boolean = false
>(
path: TFieldName,
op: "in" | "notIn",
value: V
value: InValues<FieldPathValue<TFieldValues, TFieldName>>
): (
current: IsCurrentInitial extends true ? Query<TFieldValues>
: QueryWhere<TFieldValues, TFieldValuesRefined, E>
Expand Down Expand Up @@ -1249,7 +1252,7 @@ export type FilterContinuations<IsCurrentInitial extends boolean = false> = {
| "notIncludes-any"
| "includes-all"
| "notIncludes-all",
value: readonly GetArV<V>[]
value: InValues<GetArV<V>>
): (
current: IsCurrentInitial extends true ? Query<TFieldValues>
: QueryWhere<TFieldValues, TFieldValuesRefined, E>
Expand Down Expand Up @@ -1318,12 +1321,12 @@ export type FilterContinuationsWithSubpath = {
TFieldName extends FieldPath<TFieldValues>,
TFieldValuesSub extends TFieldValues[TFieldName][number],
TFieldNameSub extends FieldPath<TFieldValuesSub>,
const V extends readonly FieldPathValue<TFieldValuesSub, TFieldNameSub>[]
V extends FieldPathValue<TFieldValuesSub, TFieldNameSub>
>(
subPath: TFieldName,
restPath: TFieldNameSub,
op: "in" | "notIn",
value: V
value: InValues<V>
): (
current: Query<TFieldValues>
) => QueryWhere<TFieldValues>
Expand Down Expand Up @@ -1357,7 +1360,7 @@ export type FilterContinuationsWithSubpath = {
| "notIncludes-any"
| "includes-all"
| "notIncludes-all",
value: readonly GetArV<V>[]
value: InValues<GetArV<V>>
): (
current: Query<TFieldValues>
) => QueryWhere<TFieldValues>
Expand Down
57 changes: 19 additions & 38 deletions packages/effect-app/src/Schema/ext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,6 @@ export const withDefaultParseOptions = <Decode extends DecodeLike>(
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)",
Expand All @@ -107,12 +105,15 @@ export interface DateFromString extends S.decodeTo<S.Date, S.String> {}
* 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
Expand All @@ -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, {
Expand Down Expand Up @@ -337,10 +318,10 @@ export const ReadonlyMapFromArray = <KeySchema extends S.Top, ValueSchema extend
return schema
}

/** Like the default Schema `ReadonlySet` but from Array, with default helpers. */
/** Like the default Schema `ReadonlySet` (Encoded is `Set`) with default helpers. */
export const ReadonlySet = <ValueSchema extends S.Top>(value: ValueSchema) =>
pipe(
ReadonlySetFromArray(value),
S.ReadonlySet(value),
(s) =>
Object.assign(s, {
/**
Expand All @@ -365,13 +346,13 @@ export const ReadonlySet = <ValueSchema extends S.Top>(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 = <KeySchema extends S.Top, ValueSchema extends S.Top>(pair: {
readonly key: KeySchema
readonly value: ValueSchema
}) =>
pipe(
ReadonlyMapFromArray(pair),
S.ReadonlyMap(pair.key, pair.value),
(s) =>
Object.assign(s, {
/**
Expand Down Expand Up @@ -503,9 +484,9 @@ export type WithDefaults<Self extends S.Top> = (
// export type UnionToIntersection3<U> = (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
Expand Down
7 changes: 6 additions & 1 deletion packages/effect-app/src/Store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -46,6 +46,11 @@ export interface StoreConfig<E> {
* 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
Expand Down
59 changes: 33 additions & 26 deletions packages/effect-app/test/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof schema>

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", () => {
Expand Down Expand Up @@ -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<typeof schema>
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
Expand Down Expand Up @@ -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]]))
})
Expand Down Expand Up @@ -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<ReadonlySet<string>>()
})
})

describe("ReadonlyMap (with withConstructorDefault)", () => {
Expand All @@ -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<ReadonlyMap<string, number>>()
})
})

describe("JSON Schema", () => {
Expand Down Expand Up @@ -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" },
Expand All @@ -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({
Expand Down
Loading
Loading