/* eslint-disable @typescript-eslint/no-explicit-any */ import * as Data from "effect/Data" import type * as Redacted from "effect/Redacted" import * as Semaphore from "effect/Semaphore" import type { NonEmptyReadonlyArray } from "./Array.js" import type { OptimisticConcurrencyException } from "./client/errors.js" import * as Context from "./Context.js" import * as Effect from "./Effect.js" import * as Layer from "./Layer.js" import type { FilterResult } from "./Model/filter/filterApi.js" import type { FieldValues } from "./Model/filter/types.js" import type { FieldPath } from "./Model/filter/types/path/index.js" import type { AggregateIrExpression, ComputedProjectionIrExpression, RawQuery } from "./Model/query.js" import type * as Option from "./Option.js" import { NonEmptyString255 } from "./Schema.js" /** * Adapter-neutral unique-key definition for stores that support unique indexes, * such as the Cosmos adapter. This shape is intentionally kept structurally * compatible with adapter-specific `UniqueKey` types. Each path identifies a * field participating in the unique key, and adapters forward these paths * directly to the underlying storage engine. */ export interface UniqueKey { readonly paths: string[] } export interface StoreConfig { partitionValue: (e?: E) => string /** * Primarily used for testing, creating namespaces in the database to separate data e.g to run multiple tests in isolation within the same database. * Memory/Disk use separate store instances per namespace. CosmosDB uses namespace-prefixed partition keys. SQL uses a `_namespace` column. */ allowNamespace?: (namespace: string) => boolean /** * just in time migrations, supported by the database driver, supporting queries, for simple default values */ defaultValues?: Partial /** * How many items can be processed in one batch at a time. * Defaults to 100 for CosmosDB. */ maxBulkSize?: number /** * Unique indexes, mainly for CosmosDB */ uniqueKeys?: UniqueKey[] } export type SupportedValues = string | boolean | number | null export type SupportedValues2 = string | boolean | number // default is eq export type Where = | { key: string; t?: "eq" | "not-eq"; value: SupportedValues } | { key: string; t: "gt" | "lt" | "gte" | "lte"; value: SupportedValues2 } | { key: string t: "contains" | "starts-with" | "ends-with" | "not-contains" | "not-starts-with" | "not-ends-with" value: string } | { key: string; t: "includes" | "not-includes"; value: string } | { key: string t: "in" | "not-in" value: readonly (SupportedValues)[] } export type Filter = readonly FilterResult[] export interface O { key: FieldPath direction: "ASC" | "DESC" } export interface FilterArgs { t: Encoded filter?: Filter | undefined select?: | NonEmptyReadonlyArray< U | { key: string; subKeys: readonly string[] } | { key: string computed: ComputedProjectionIrExpression } | { key: string path: string } | { key: string aggregate: AggregateIrExpression } > | undefined order?: NonEmptyReadonlyArray>> limit?: number | undefined skip?: number | undefined } export type FilterFunc = ( args: FilterArgs ) => Effect.Effect<(U extends undefined ? Encoded : Pick)[]> export interface Store< IdKey extends keyof Encoded, Encoded extends FieldValues, PM extends PersistenceModelType = PersistenceModelType > { all: Effect.Effect filter: FilterFunc find: (id: Encoded[IdKey]) => Effect.Effect> set: (e: PM) => Effect.Effect batchSet: ( items: NonEmptyReadonlyArray ) => Effect.Effect, OptimisticConcurrencyException> bulkSet: ( items: NonEmptyReadonlyArray ) => Effect.Effect, OptimisticConcurrencyException> batchRemove: (ids: NonEmptyReadonlyArray, partitionKey?: string) => Effect.Effect queryRaw: (query: RawQuery) => Effect.Effect /** * Explicitly seed a namespace. Primary is seeded eagerly on initialization. * Non-primary namespaces must be seeded explicitly before use. */ seedNamespace: (namespace: string) => Effect.Effect } export class StoreMaker extends Context.Opaque( name: string, idKey: IdKey, seed?: Effect.Effect, E, R>, config?: StoreConfig ) => Effect.Effect, E, R> }>()("effect-app/StoreMaker") { } export const makeContextMap = () => { const etags = new Map() const getEtag = (id: string) => etags.get(id) const setEtag = (id: string, eTag: string | undefined) => { if (eTag === undefined) { etags.delete(id) } else { etags.set(id, eTag) } } // const parsedCache = new Map< // Parser, // Map> // >() // const parserCache = new Map< // Parser, // (i: any) => These.These // >() // const setAndReturn = ( // p: Parser, // np: (i: I) => These.These // ) => { // parserCache.set(p, np) // return np // } // const parserEnv: ParserEnv = { // // TODO: lax: true would turn off refinement checks, may help on large payloads // // but of course removes confirming of validation rules (which may be okay for a database owned by the app, as we write safely) // lax: false, // cache: { // getOrSetParser: (p) => parserCache.get(p) ?? setAndReturn(p, (i) => parserEnv.cache!.getOrSet(i, p)), // getOrSetParsers: (parsers) => { // return Object.entries(parsers).reduce((prev, [k, v]) => { // prev[k] = parserEnv.cache!.getOrSetParser(v) // return prev // }, {} as any) // }, // getOrSet: (i, parse): any => { // const c = parsedCache.get(parse) // if (c) { // const f = c.get(i) // if (f) { // // console.log("$$$ cache hit", i) // return f // } else { // const nf = parse(i, parserEnv) // c.set(i, nf) // return nf // } // } else { // const nf = parse(i, parserEnv) // parsedCache.set(parse, new Map([[i, nf]])) // return nf // } // } // } // } const store = new Map() const sem = Semaphore.makeUnsafe(1) return { createdAt: new Date(), get: getEtag, set: setEtag, getOrCreateStore: (key: symbol, make: () => T): T => { let value = store.get(key) as T | undefined if (value === undefined) { value = make() store.set(key, value) } return value }, getOrCreateStoreEffect: (key: symbol, make: Effect.Effect): Effect.Effect => sem.withPermits(1)(Effect.uninterruptible(Effect.gen(function*() { const value = store.get(key) as T | undefined if (value !== undefined) return value const v = yield* make store.set(key, v) return v }))), clear: () => { etags.clear() store.clear() } } } const makeMap = Effect.acquireRelease( Effect.sync(() => makeContextMap()), (m) => Effect.sync(() => m.clear()) ) export class ContextMap extends Context.Opaque()("effect-app/ContextMap", { make: makeMap }) { } export class ContextMapContainer extends Context.Reference("ContextMapContainer", { defaultValue: (): ContextMap | "root" => "root" }) { static readonly layer = Layer.effect(this, ContextMap.make.pipe(Effect.map(ContextMap.of))) } export class ContextMapNotStartedError extends Data.TaggedError("ContextMapNotStartedError") {} export const getContextMap = ContextMapContainer.pipe( Effect.filterOrFail((_) => _ !== "root", () => new ContextMapNotStartedError()) ) /** * Runs `make` at most once per ContextMap (i.e. per request) and caches the * resulting value in the ContextMap under a fresh symbol. Subsequent calls of * the returned Effect within the same ContextMap return the cached value. * * Uses the ContextMap's shared semaphore for safe single initialization. */ export const cachedPerRequest = ( make: Effect.Effect ): Effect.Effect => { const cacheKey = Symbol() return getContextMap.pipe( Effect.flatMap((ctxMap) => ctxMap.getOrCreateStoreEffect(cacheKey, make)) ) } const defaultNs: NonEmptyString255 = NonEmptyString255("primary") export class storeId extends Context.Reference("StoreId", { defaultValue: (): NonEmptyString255 => defaultNs }) {} export type PersistenceModelType = Encoded & { _etag?: string | undefined } export interface StorageConfig { url: Redacted.Redacted prefix: string dbName: string }