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, {