diff --git a/packages/effect-app/src/Model/Repository/ext.ts b/packages/effect-app/src/Model/Repository/ext.ts index 9ecdc8d40..d8fedfbef 100644 --- a/packages/effect-app/src/Model/Repository/ext.ts +++ b/packages/effect-app/src/Model/Repository/ext.ts @@ -5,7 +5,7 @@ import * as Request from "effect/Request" import * as RequestResolver from "effect/RequestResolver" import * as Array from "../../Array.ts" import type { NonEmptyArray } from "../../Array.ts" -import { type InvalidStateError, NotFoundError, type OptimisticConcurrencyException } from "../../client/errors.ts" +import { type DatabaseError, type InvalidStateError, NotFoundError, type OptimisticConcurrencyException } from "../../client/errors.ts" import * as Effect from "../../Effect.ts" import * as Option from "../../Option.ts" import { type FixEnv, type PureEnv, runTerm } from "../../Pure.ts" @@ -89,7 +89,11 @@ export const extendRepo = < A >( gen: Effect.Effect, Iterable, A], E, R> - ) { + ): Effect.Effect< + A, + E | InvalidStateError | OptimisticConcurrencyException | DatabaseError, + R | RSchema | RPublish + > { return Effect.flatMap(gen, ([items, events, a]) => repo.saveAndPublish(items, events).pipe(Effect.map(() => a))) } @@ -113,6 +117,8 @@ export const extendRepo = < ) } + type PureStoreError = InvalidStateError | OptimisticConcurrencyException | DatabaseError + const queryAndSavePure: { ( q: ( @@ -121,8 +127,10 @@ export const extendRepo = < pure: Effect.Effect> ): Effect.Effect< A, - InvalidStateError | OptimisticConcurrencyException | NotFoundError | E2, - Exclude | E2, + | RSchema + | RPublish + | Exclude }> > @@ -136,7 +144,7 @@ export const extendRepo = < pure: Effect.Effect> ): Effect.Effect< A, - InvalidStateError | OptimisticConcurrencyException | E2, + PureStoreError | E2, | RSchema | RPublish | Exclude }> > - } = (q, pure, batch?: "batched" | number) => + } = ((q: any, pure: any, batch?: "batched" | number) => + // Overload dispatch: query returns T | T[]; pure helpers are generic on item shape. + // Runtime path is query → pure term → saveAndPublish (which raises DatabaseError). repo.query(q).pipe( Effect.andThen((_) => Array.isArray(_) ? batch === undefined - ? saveManyWithPure_(_ as any, pure as any) - : saveManyWithPureBatched_(_ as any, pure as any, batch === "batched" ? 100 : batch) - : saveWithPure_(_ as any, pure as any) + ? saveManyWithPure_(_ as T[], pure) + : saveManyWithPureBatched_(_ as T[], pure, batch === "batched" ? 100 : batch) + : saveWithPure_(_ as T, pure) ) - ) as any + )) as typeof queryAndSavePure const saveManyWithPure: { ( @@ -178,7 +188,7 @@ export const extendRepo = < pure: Effect.Effect> ): Effect.Effect< A, - InvalidStateError | OptimisticConcurrencyException | E, + PureStoreError | E, | RSchema | RPublish | Exclude }> > - } = (items, pure, batch?: "batched" | number) => + } = ((items: Iterable, pure: any, batch?: "batched" | number) => batch ? Effect.forEach( - Array.chunksOf(items, batch === "batched" ? 100 : batch), - (batch) => + Array.chunksOf([...items], batch === "batched" ? 100 : batch), + (batchItems) => saveAllWithEffectInt( - runTerm(pure, batch) + runTerm(pure, batchItems as any) ) ) : saveAllWithEffectInt( - runTerm(pure, [...items]) - ) + runTerm(pure, [...items] as any) + )) as typeof saveManyWithPure const byIdAndSaveWithPure: { ( @@ -217,17 +227,19 @@ export const extendRepo = < pure: Effect.Effect> ): Effect.Effect< A, - InvalidStateError | OptimisticConcurrencyException | NotFoundError | E, + PureStoreError | NotFoundError | E, | RSchema | RPublish | Exclude }> > - } = (id, pure): any => get(id).pipe(Effect.flatMap((item) => saveWithPure_(item, pure))) + } = (id, pure) => get(id).pipe(Effect.flatMap((item) => saveWithPure_(item, pure))) + // query can raise DatabaseError; NotFound is completed per-entry. Dual package + // round-trips may still surface other store errors via failCause — keep channel honest. type Req = - & Request.Request> + & Request.Request | DatabaseError> & { _tag: `Get${ItemType}`; id: T[IdKey] } const _request = Request.tagged(`Get${repo.itemType}`) @@ -237,9 +249,9 @@ export const extendRepo = < _key: unknown ) => (repo.query(Q.where(repo.idKey as any, "in" as any, entries.map((_) => _.request.id)) as any) as Effect.Effect< - readonly T[] + readonly T[], + DatabaseError >) - // TODO .pipe( Effect.andThen((items) => Effect.forEach(entries, (entry) => @@ -270,7 +282,7 @@ export const extendRepo = < * Enables chunked writes for large batches via `options.batch`. * Note: batching breaks transactional properties because chunks are saved independently. */ - save: ((itemOrItems: T | ReadonlyArray, options?: BatchOptions) => { + save: (itemOrItems: T | ReadonlyArray, options?: BatchOptions) => { const items = asReadonlyArray(itemOrItems) if (!Array.isReadonlyArrayNonEmpty(items)) { return Effect.void @@ -284,23 +296,16 @@ export const extendRepo = < (batch) => repo.saveAndPublish(batch), { discard: true } ) - }) as ( - itemOrItems: T | ReadonlyArray, - options?: BatchOptions - ) => Effect.Effect< - void, - InvalidStateError | OptimisticConcurrencyException, - RSchema | RPublish - >, + }, saveWithEvents: (events: Iterable) => (...items: NonEmptyArray) => repo.saveAndPublish(items, events), /** * Enables chunked deletes for large batches via `options.batch`. * Note: batching breaks transactional properties because chunks are removed independently. */ - remove: ((itemOrItems: T | ReadonlyArray, options?: BatchOptions) => { + remove: (itemOrItems: T | ReadonlyArray, options?: BatchOptions) => { const items = asReadonlyArray(itemOrItems) if (!Array.isReadonlyArrayNonEmpty(items)) { - return Effect.void + return Effect.void as Effect.Effect } const batchSize = getBatchSize(options?.batch) if (batchSize === undefined) { @@ -311,18 +316,15 @@ export const extendRepo = < (batch) => repo.removeAndPublish(batch), { discard: true } ) - }) as ( - itemOrItems: T | ReadonlyArray, - options?: BatchOptions - ) => Effect.Effect, + }, /** * Enables chunked deletes for large batches via `options.batch`. * Note: batching breaks transactional properties because chunks are removed independently. */ - removeById: ((idOrIds: T[IdKey] | ReadonlyArray, options?: BatchOptions) => { + removeById: (idOrIds: T[IdKey] | ReadonlyArray, options?: BatchOptions) => { const ids = asReadonlyArray(idOrIds) if (!Array.isReadonlyArrayNonEmpty(ids)) { - return Effect.void + return Effect.void as Effect.Effect } const batchSize = getBatchSize(options?.batch) if (batchSize === undefined) { @@ -333,10 +335,7 @@ export const extendRepo = < (batch) => repo.removeById(batch), { discard: true } ) - }) as ( - idOrIds: T[IdKey] | ReadonlyArray, - options?: BatchOptions - ) => Effect.Effect, + }, queryAndSavePure, saveManyWithPure, byIdAndSaveWithPure, diff --git a/packages/infra/src/Store/SQL.ts b/packages/infra/src/Store/SQL.ts index 7064e6b60..f612a2e50 100644 --- a/packages/infra/src/Store/SQL.ts +++ b/packages/infra/src/Store/SQL.ts @@ -25,6 +25,16 @@ const sqlIsTransient = (e: unknown) => // which would turn it into an opaque, non-serializable defect). const toDatabaseError = (e: unknown) => new DatabaseError({ message: `SQL request failed: ${sqlErrorMessage(e)}`, transient: sqlIsTransient(e), cause: e }) +// withTransaction may re-raise setInternal's typed errors or add SqlError on begin/commit. +// Preserve DatabaseError / OCC by `_tag` (not instanceof — dual package instances break it); +// map residual SQL failures to DatabaseError. +const preserveStoreError = (e: unknown): DatabaseError | OptimisticConcurrencyException => { + if (e !== null && typeof e === "object" && "_tag" in e) { + if (e._tag === "DatabaseError") return e as DatabaseError + if (e._tag === "OptimisticConcurrencyException") return e as OptimisticConcurrencyException + } + return toDatabaseError(e) +} export type WithNsTransactionFn = (effect: Effect.Effect) => Effect.Effect @@ -155,7 +165,7 @@ function makeSQLStoreInt(system: DbSystem, dialect: SQLDialect, jsonColumnType: sql .withTransaction(Effect.forEach(items, (e) => setInternal(e, ns))) .pipe( - Effect.orDie, + Effect.mapError(preserveStoreError), Effect.map((_) => _ as unknown as NonEmptyReadonlyArray) ) @@ -172,7 +182,12 @@ function makeSQLStoreInt(system: DbSystem, dialect: SQLDialect, jsonColumnType: yield* InfraLogger.logInfo(`Seeding data for ${name} (namespace: ${ns})`) const items = yield* seed.pipe(Effect.provide(ctx), Effect.orDie) const ne = toNonEmptyArray([...items]) - if (Option.isSome(ne)) yield* bulkSetInternal(ne.value, ns) + // Seed inserts are not concurrent; OCC here is a programming defect. + if (Option.isSome(ne)) { + yield* bulkSetInternal(ne.value, ns).pipe( + Effect.catchTag("OptimisticConcurrencyException", Effect.die) + ) + } yield* exec( `INSERT INTO "_migrations" (id, version) VALUES (?, ?)`, [`${tableName}::${ns}`, tableName] @@ -488,7 +503,7 @@ function makeSQLiteStorePerNs( sql .withTransaction(Effect.forEach(items, (e) => setInternal(e, ns))) .pipe( - Effect.orDie, + Effect.mapError(preserveStoreError), Effect.map((_) => _ as unknown as NonEmptyReadonlyArray) )) @@ -506,7 +521,12 @@ function makeSQLiteStorePerNs( yield* InfraLogger.logInfo(`Seeding data for ${name} (namespace: ${ns})`) const items = yield* seed.pipe(Effect.provide(ctx), Effect.orDie) const ne = toNonEmptyArray([...items]) - if (Option.isSome(ne)) yield* bulkSetInternal(ne.value, ns) + // Seed inserts are not concurrent; OCC here is a programming defect. + if (Option.isSome(ne)) { + yield* bulkSetInternal(ne.value, ns).pipe( + Effect.catchTag("OptimisticConcurrencyException", Effect.die) + ) + } yield* exec( ns, `INSERT INTO "_migrations" (id, version) VALUES (?, ?)`, diff --git a/packages/infra/src/Store/SQL/Pg.ts b/packages/infra/src/Store/SQL/Pg.ts index b87c4a041..4fffa4e8c 100644 --- a/packages/infra/src/Store/SQL/Pg.ts +++ b/packages/infra/src/Store/SQL/Pg.ts @@ -9,12 +9,30 @@ import * as Option from "effect-app/Option" import { type FilterArgs, type PersistenceModelType, type StorageConfig, type Store, type StoreConfig, storeId, StoreMaker } from "effect-app/Store" import * as Struct from "effect/Struct" import { SqlClient } from "effect/unstable/sql" -import { OptimisticConcurrencyException } from "../../errors.ts" +import { DatabaseError, OptimisticConcurrencyException } from "../../errors.ts" import { InfraLogger } from "../../logger.ts" import { annotateDb } from "../../otel.ts" import { makeETag } from "../utils.ts" import { buildWhereSQLQuery, logQuery, pgDialect } from "./query.ts" +const sqlErrorMessage = (e: unknown) => (e as any)?.message ? String((e as any).message) : String(e) +const sqlIsTransient = (e: unknown) => + /timeout|ETIMEDOUT|ECONNRESET|ECONNREFUSED|connection|deadlock|too many connections/i.test(sqlErrorMessage(e)) +// Map a SqlError into a typed, serializable DatabaseError (instead of `.orDie`, +// which would turn it into an opaque, non-serializable defect). +const toDatabaseError = (e: unknown) => + new DatabaseError({ message: `SQL request failed: ${sqlErrorMessage(e)}`, transient: sqlIsTransient(e), cause: e }) +// withTransaction may re-raise setInternal's typed errors or add SqlError on begin/commit. +// Preserve DatabaseError / OCC by `_tag` (not instanceof — dual package instances break it); +// map residual SQL failures to DatabaseError. +const preserveStoreError = (e: unknown): DatabaseError | OptimisticConcurrencyException => { + if (e !== null && typeof e === "object" && "_tag" in e) { + if (e._tag === "DatabaseError") return e as DatabaseError + if (e._tag === "OptimisticConcurrencyException") return e as OptimisticConcurrencyException + } + return toDatabaseError(e) +} + const parseRow = ( row: { id: string; _etag: string | null; data: unknown }, idKey: PropertyKey, @@ -85,7 +103,8 @@ const makePgStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) { return { id, _etag: newE._etag!, data, item: newE } } - const exec = (query: string, params?: readonly unknown[]) => sql.unsafe(query, params as any).pipe(Effect.orDie) + const exec = (query: string, params?: readonly unknown[]) => + sql.unsafe(query, params as any).pipe(Effect.mapError(toDatabaseError)) const setInternal = Effect.fnUntraced(function*(e: PM, ns: string) { const row = toRow(e) @@ -130,12 +149,12 @@ const makePgStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) { sql .withTransaction(Effect.forEach(items, (e) => setInternal(e, ns))) .pipe( - Effect.orDie, + Effect.mapError(preserveStoreError), Effect.map((_) => _ as unknown as NonEmptyReadonlyArray) ) const ctx = yield* Effect.context() - const seedCache = new Map>() + const seedCache = new Map>() const makeSeedEffect = Effect.fnUntraced(function*(ns: string) { yield* ensureTable if (!seed) return @@ -147,7 +166,12 @@ const makePgStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) { yield* InfraLogger.logInfo(`Seeding data for ${name} (namespace: ${ns})`) const items = yield* seed.pipe(Effect.provide(ctx), Effect.orDie) const ne = toNonEmptyArray([...items]) - if (Option.isSome(ne)) yield* bulkSetInternal(ne.value, ns) + // Seed inserts are not concurrent; OCC here is a programming defect. + if (Option.isSome(ne)) { + yield* bulkSetInternal(ne.value, ns).pipe( + Effect.catchTag("OptimisticConcurrencyException", Effect.die) + ) + } yield* exec( `INSERT INTO "_migrations" (id, version) VALUES ($1, $2)`, [`${tableName}::${ns}`, tableName] @@ -349,8 +373,10 @@ const makePgStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) { ) } - // Eagerly seed primary namespace on initialization - yield* seedNamespace("primary") + // Eagerly seed primary namespace on initialization. A seed failure at + // construction is fatal (orDie); the per-call `seedNamespace` still + // surfaces DatabaseError to callers. + yield* seedNamespace("primary").pipe(Effect.orDie) return s }) diff --git a/packages/vue/src/query.ts b/packages/vue/src/query.ts index 372b02c1c..302996d1f 100644 --- a/packages/vue/src/query.ts +++ b/packages/vue/src/query.ts @@ -16,7 +16,7 @@ import * as Exit from "effect/Exit" import * as Stream from "effect/Stream" import * as AsyncResult from "effect/unstable/reactivity/AsyncResult" import * as Atom from "effect/unstable/reactivity/Atom" -import { computed, type ComputedRef, effectScope, type MaybeRefOrGetter, onBeforeUnmount, onMounted, onScopeDispose, type Ref, ref, toValue, type WatchSource } from "vue" +import { computed, type ComputedRef, effectScope, type MaybeRefOrGetter, onBeforeUnmount, onMounted, onScopeDispose, ref, toValue, type WatchSource } from "vue" import { type AtomClientRuntime, type AtomQueryOptions, awaitAtomResult, buildQueryFamily, buildStreamQueryFamily, disabledQueryAtom, isStaleResult, refreshAtomWithCurrentSpan, staleTimeMsOf, withQueryOptions } from "./atomQuery.ts" import { latestDefined } from "./suspense.ts" @@ -276,7 +276,8 @@ export interface AtomStreamQueryOptions { * option is set. Neither value is written to the atom cache. */ export const withDataFallback = ( - rawResult: Ref>, + // Structural read so both Ref and Readonly from useAtomValue are accepted. + rawResult: { readonly value: AsyncResult.AsyncResult }, options?: unknown ): ComputedRef> => { const opts = options as @@ -285,6 +286,8 @@ export const withDataFallback = ( const initialData = opts?.initialData const placeholderData = opts?.placeholderData if (initialData === undefined && placeholderData === undefined) { + // Identity when no fallback options. Callers that need a real ComputedRef + // (e.g. useAtomValue's Readonly) wrap before calling this helper. return rawResult as ComputedRef> } @@ -549,7 +552,9 @@ const makeQueryView = ( const [req, enabledRef] = optionValue(arg, options) const family = getQueryFamily(atomRt, q) const atomRef = computed(() => enabledRef.value ? observedAtom(family(req.value), options) : disabledQueryAtom) - const rawResult = useAtomValue(() => atomRef.value) as ComputedRef> + // useAtomValue returns Readonly, not ComputedRef — re-wrap before withDataFallback / QueryView. + const rawAtomResult = useAtomValue(() => atomRef.value) + const rawResult = computed(() => rawAtomResult.value) // `initialData` / `placeholderData` display fallback (old `.query()` API). Applied only while the // result is still Initial; Failures/Success pass through unmasked. Neither is written to cache // (the base atom stays Initial, so the mount-staleness check still triggers the real fetch). The