import { Cause, Context, Data, Effect, Exit, Layer, Predicate } from "effect"; import type { AbstractQuery } from "@executor-js/fumadb/query"; import type { AnySchema, AnyTable, Schema as FumaSchema } from "@executor-js/fumadb/schema"; export class StorageError extends Data.TaggedError("StorageError")<{ readonly message: string; readonly cause: unknown; }> {} export class UniqueViolationError extends Data.TaggedError("UniqueViolationError")<{ readonly model?: string; }> {} export type StorageFailure = StorageError | UniqueViolationError; export type FumaTables = Record; type EmptyFumaSchema = FumaSchema<"latest", Record>; export type TablesToFumaSchema = TTables extends FumaTables ? string extends keyof TTables ? AnySchema : FumaSchema<"latest", TTables> : EmptyFumaSchema; export type FumaDb = AbstractQuery; export type FumaQuery = Omit< AbstractQuery, "internal" | "withContext" | "transaction" > & { readonly transaction: (run: (db: FumaQuery) => Promise) => Promise; }; export type FumaRow = Omit< { readonly [K in keyof TTable["columns"]]: TTable["columns"][K]["$out"]; }, "row_id" >; const isUniqueViolation = (cause: unknown): boolean => { let current = cause; for (let i = 0; i < 5; i += 1) { const err = current && typeof current === "object" ? (current as Record) : null; if (!err) return false; const code = err["code"]; // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: database drivers expose unique-violation details on native error messages const message = err["message"]; const innerCause = err["cause"]; if (code === "23505") return true; if ( typeof message === "string" && /unique constraint|duplicate key|violates unique constraint/i.test(message) ) { return true; } if (!innerCause || innerCause === current) return false; current = innerCause; } return false; }; const causeMessage = (cause: unknown): string | undefined => { const message = cause && typeof cause === "object" ? // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: preserve database driver error text inside typed StorageError (cause as Record)["message"] : undefined; return typeof message === "string" && message.length > 0 ? message : undefined; }; export const isStorageFailure = (error: unknown): error is StorageFailure => Predicate.isTagged(error, "StorageError") || Predicate.isTagged(error, "UniqueViolationError"); export const fumaFailureFromCause = (label: string, cause: unknown): StorageFailure => { if (isStorageFailure(cause)) return cause; if (isUniqueViolation(cause)) return new UniqueViolationError({ model: label }); return new StorageError({ message: causeMessage(cause) ?? `FumaDB operation failed: ${label}`, cause, }); }; export const fumaEffect = ( label: string, run: () => Promise, ): Effect.Effect => Effect.tryPromise({ try: run, catch: (cause) => fumaFailureFromCause(label, cause), }); export const activeFumaDbRef = Context.Reference("executor/ActiveFumaDb", { defaultValue: () => null, }); // Post-commit hooks. `transaction()` nests by pass-through (an inner // `transaction()` call inside an active transaction just runs its effect), so // an effect that must observe only DURABLE changes cannot simply run "after // the transaction" — after an inner pass-through the outer transaction may // still roll back. `afterCommit` solves this structurally: while a transaction // is active the effect is queued on the outermost transaction's hook list and // runs after its commit; with no active transaction it runs immediately. // Hooks are best-effort observers: failures and defects are swallowed, and a // rolled-back transaction discards its queue. const pendingCommitHooksRef = Context.Reference> | null>( "executor/PendingCommitHooks", { defaultValue: () => null }, ); export const afterCommit = (effect: Effect.Effect): Effect.Effect => Effect.flatMap(Effect.service(pendingCommitHooksRef), (hooks) => hooks ? Effect.sync(() => { hooks.push(effect); }) : effect.pipe(Effect.ignoreCause({ log: false })), ); class TransactionEffectFailure { constructor(readonly error: unknown) {} } class TransactionEffectDefect { constructor(readonly cause: unknown) {} } export type IFumaClient = Readonly<{ use: ( label: string, fn: (db: FumaQuery) => Promise, ) => Effect.Effect; transaction: (effect: Effect.Effect) => Effect.Effect; }>; export interface MakeFumaClientOptions { readonly tables?: ReadonlySet; } const isAllowedTable = (tables: ReadonlySet | undefined, table: PropertyKey): boolean => tables === undefined || (typeof table === "string" && tables.has(table)); const assertAllowedTable = (tables: ReadonlySet | undefined, table: PropertyKey): void => { if (isAllowedTable(tables, table)) return; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: plugin-facing FumaDB facade rejects unavailable tables synchronously before query execution throw new StorageError({ message: `FumaDB table "${String(table)}" is not available through this storage boundary.`, cause: undefined, }); }; const makeSafeFumaQuery = ( db: FumaDb, options: MakeFumaClientOptions, ): FumaQuery => { const table = (name: TableName): TableName => { assertAllowedTable(options.tables, name); return name; }; const query: FumaQuery = { count: (name, value) => db.count(table(name), value), create: (name, value) => db.create(table(name), value), createMany: (name, values) => db.createMany(table(name), values), deleteMany: (name, value) => db.deleteMany(table(name), value), findFirst: (name, value) => db.findFirst(table(name), value), findMany: (name, value) => db.findMany(table(name), value), transaction: (run) => db.transaction((transactionDb) => run(makeSafeFumaQuery(transactionDb, options))), updateMany: (name, value) => db.updateMany(table(name), value), upsert: (name, value) => db.upsert(table(name), value), }; return Object.freeze(query); }; export const makeFumaClient = (db: FumaDb, options: MakeFumaClientOptions = {}): IFumaClient => { const use: IFumaClient["use"] = (label, fn) => Effect.flatMap(Effect.service(activeFumaDbRef), (active) => fumaEffect(label, () => fn(makeSafeFumaQuery(active ?? db, options))), ).pipe(Effect.withSpan(`fumadb.${label}`)); const transaction = (effect: Effect.Effect): Effect.Effect => Effect.flatMap(Effect.service(activeFumaDbRef), (active) => { if (active) return effect as Effect.Effect; // The outermost transaction owns the post-commit hook queue; hooks // queued anywhere inside (including nested pass-through transactions) // run only after THIS commit, and are discarded on rollback. const commitHooks: Array> = []; return Effect.tryPromise({ try: () => db.transaction(async (transactionDb) => { const exit = await Effect.runPromiseExit( effect.pipe( Effect.provideService(activeFumaDbRef, transactionDb), Effect.provideService(pendingCommitHooksRef, commitHooks), ), ); if (Exit.isSuccess(exit)) return exit.value; const failure = exit.cause.reasons.find(Cause.isFailReason); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: FumaDB transactions roll back when the callback rejects if (failure) throw new TransactionEffectFailure(failure.error); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: FumaDB transactions roll back when the callback rejects throw new TransactionEffectDefect(exit.cause); }), catch: (cause): E | StorageFailure => { if (cause instanceof TransactionEffectFailure) return cause.error as E; if (cause instanceof TransactionEffectDefect) { return fumaFailureFromCause("transaction", cause.cause); } return fumaFailureFromCause("transaction", cause); }, }).pipe( Effect.tap(() => Effect.forEach(commitHooks, (hook) => hook.pipe(Effect.ignoreCause({ log: false })), { discard: true, }), ), ); }).pipe(Effect.withSpan("fumadb.transaction")) as Effect.Effect; return { use, transaction }; }; export class FumaClient extends Context.Service()("executor/FumaClient") { static layer = (db: FumaDb) => Layer.succeed(this)(makeFumaClient(db)); }