From 23c8ca50e9f9ca44c80bfc776b4ba63b7a8958ec Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 26 Oct 2024 15:00:10 +0200 Subject: [PATCH] R/idkey-in-driver (#93) * prepare * wrap up * progress * nice * fix typo * cleanup * fix cosmos query replace idKey * doc * add changeset --- .changeset/kind-chefs-try.md | 5 + packages/infra/src/Model/Repository/ext.ts | 11 +- .../src/Model/Repository/internal/internal.ts | 73 +++++----- packages/infra/src/Model/Repository/legacy.ts | 4 +- .../infra/src/Model/Repository/makeRepo.ts | 17 ++- .../infra/src/Model/Repository/service.ts | 2 +- packages/infra/src/Store/Cosmos.ts | 133 +++++++++++------- packages/infra/src/Store/Cosmos/query.ts | 7 +- packages/infra/src/Store/Disk.ts | 28 ++-- packages/infra/src/Store/Memory.ts | 41 +++--- packages/infra/src/Store/codeFilter.ts | 3 +- packages/infra/src/Store/service.ts | 15 +- packages/infra/src/Store/utils.ts | 7 +- 13 files changed, 209 insertions(+), 137 deletions(-) create mode 100644 .changeset/kind-chefs-try.md diff --git a/.changeset/kind-chefs-try.md b/.changeset/kind-chefs-try.md new file mode 100644 index 000000000..b2db47425 --- /dev/null +++ b/.changeset/kind-chefs-try.md @@ -0,0 +1,5 @@ +--- +"@effect-app/infra": minor +--- + +pushed idKey mapping down to the driver instead of the repo diff --git a/packages/infra/src/Model/Repository/ext.ts b/packages/infra/src/Model/Repository/ext.ts index 90246d0b0..3045b32e7 100644 --- a/packages/infra/src/Model/Repository/ext.ts +++ b/packages/infra/src/Model/Repository/ext.ts @@ -7,16 +7,17 @@ import { NotFoundError } from "effect-app/client" import type { FixEnv, PureEnv } from "effect-app/Pure" import { runTerm } from "effect-app/Pure" import { AnyPureDSL } from "../dsl.js" +import type { FieldValues } from "../filter/types.js" import type { Query, QueryEnd, QueryWhere } from "../query.js" import * as Q from "../query.js" import type { Repository } from "./service.js" export const extendRepo = < T, - Encoded extends { id: string }, + Encoded extends FieldValues, Evt, ItemType extends string, - IdKey extends keyof T, + IdKey extends keyof T & keyof Encoded, RSchema, RPublish >( @@ -212,7 +213,7 @@ export const extendRepo = < .makeBatched(( requests: NonEmptyReadonlyArray ) => - (repo.query(Q.where("id", "in", requests.map((_) => _.id)) as any) as Effect) + (repo.query(Q.where(repo.idKey as any, "in", requests.map((_) => _.id)) as any) as Effect) // TODO .pipe( Effect.andThen((items) => @@ -272,10 +273,10 @@ export const extendRepo = < // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface ExtendedRepository< T, - Encoded extends { id: string }, + Encoded extends FieldValues, Evt, ItemType extends string, - IdKey extends keyof T, + IdKey extends keyof T & keyof Encoded, RSchema, RPublish > extends ReturnType> {} diff --git a/packages/infra/src/Model/Repository/internal/internal.ts b/packages/infra/src/Model/Repository/internal/internal.ts index 3009ed56f..2e90e773d 100644 --- a/packages/infra/src/Model/Repository/internal/internal.ts +++ b/packages/infra/src/Model/Repository/internal/internal.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import type {} from "effect/Equal" import type {} from "effect/Hash" import type { NonEmptyReadonlyArray } from "effect-app" @@ -26,9 +27,9 @@ export function makeRepoInternal< return < ItemType extends string, R, - Encoded extends { id: string }, + Encoded extends FieldValues, T, - IdKey extends keyof T + IdKey extends keyof T & keyof Encoded >( name: ItemType, schema: S.Schema, @@ -41,18 +42,18 @@ export function makeRepoInternal< e: Encoded, getEtag: (id: string) => string | undefined ): PM { - return mapTo(e, getEtag(e.id)) + return mapTo(e, getEtag(e[idKey])) } function mapReverse( { _etag, ...e }: PM, setEtag: (id: string, eTag: string | undefined) => void ): Encoded { - setEtag(e.id, _etag) + setEtag((e as any)[idKey], _etag) return mapFrom(e as unknown as Encoded) } - const mkStore = makeStore()(name, schema, mapTo) + const mkStore = makeStore()(name, schema, mapTo, idKey) function make( args: [Evt] extends [never] ? { @@ -128,7 +129,7 @@ export function makeRepoInternal< : s.pipe(S.pick(idKey as any)) }) const encodeId = flow(S.encode(i), provideRctx) - function findEId(id: Encoded["id"]) { + function findEId(id: Encoded[IdKey]) { return Effect.flatMap( store.find(id), (item) => @@ -138,13 +139,12 @@ export function makeRepoInternal< }) ) } + // TODO: select the particular field, instead of as struct function findE(id: T[IdKey]) { return pipe( encodeId({ [idKey]: id } as any), Effect.orDie, - // we will have idKey because the transform is undone again by the encode schema mumbo jumbo above - // TODO: make reliable. (Security: isin: PrimaryKey(ISIN), idKey: "isin", does end up with "id") - Effect.map((_) => (_ as any)[idKey] ?? (_ as any).id), + Effect.map((_) => (_ as any)[idKey]), Effect.flatMap(findEId) ) } @@ -163,7 +163,7 @@ export function makeRepoInternal< const { get, set } = yield* cms const items = a.map((_) => mapToPersistenceModel(_, get)) const ret = yield* store.batchSet(items) - ret.forEach((_) => set(_.id, _._etag)) + ret.forEach((_) => set(_[idKey], _._etag)) }) ) .pipe(Effect.asVoid) @@ -199,7 +199,7 @@ export function makeRepoInternal< // TODO: we should have a batchRemove on store so the adapter can actually batch... for (const e of items) { yield* store.remove(mapToPersistenceModel(e, get)) - set(e.id, undefined) + set(e[idKey], undefined) } yield* Effect .sync(() => toNonEmptyArray([...events])) @@ -233,13 +233,13 @@ export function makeRepoInternal< { ...args, select: args.select - ? dedupe([...args.select, "id", "_etag" as any]) + ? dedupe([...args.select, idKey, "_etag" as any]) : undefined } as typeof args ) .pipe( Effect.tap((items) => - Effect.map(cms, ({ set }) => items.forEach((_) => set((_ as Encoded).id, (_ as PM)._etag))) + Effect.map(cms, ({ set }) => items.forEach((_) => set((_ as Encoded)[idKey], (_ as PM)._etag))) ) ) @@ -372,46 +372,47 @@ const pluralize = (s: string) => ? s.substring(0, s.length - 1) + "ies" : s + "s" -export function makeStore< - Encoded extends { id: string } ->() { +export function makeStore() { return < ItemType extends string, R, - E extends { id: string }, - T + E, + T, + IdKey extends keyof Encoded >( name: ItemType, schema: S.Schema, - mapTo: (e: E, etag: string | undefined) => Encoded + mapTo: (e: E, etag: string | undefined) => Encoded, + idKey: IdKey ) => { - function encodeToEncoded() { - const getEtag = () => undefined - return (t: T) => - S.encode(schema)(t).pipe( - Effect.orDie, - Effect.map((_) => mapToPersistenceModel(_, getEtag)) - ) - } - - function mapToPersistenceModel( - e: E, - getEtag: (id: string) => string | undefined - ): Encoded { - return mapTo(e, getEtag(e.id)) - } - function makeStore( makeInitial?: Effect, config?: Omit, "partitionValue"> & { partitionValue?: (a: Encoded) => string } ) { + function encodeToEncoded() { + const getEtag = () => undefined + return (t: T) => + S.encode(schema)(t).pipe( + Effect.orDie, + Effect.map((_) => mapToPersistenceModel(_, getEtag)) + ) + } + + function mapToPersistenceModel( + e: E, + getEtag: (id: string) => string | undefined + ): Encoded { + return mapTo(e, getEtag((e as any)[idKey] as string)) + } + return Effect.gen(function*() { const { make } = yield* StoreMaker - const store = yield* make( + const store = yield* make( pluralize(name), + idKey, makeInitial ? makeInitial .pipe( diff --git a/packages/infra/src/Model/Repository/legacy.ts b/packages/infra/src/Model/Repository/legacy.ts index 086f854d5..bb701f1a7 100644 --- a/packages/infra/src/Model/Repository/legacy.ts +++ b/packages/infra/src/Model/Repository/legacy.ts @@ -14,13 +14,13 @@ export interface Mapped2 { all: Effect } -export interface Mapped { +export interface Mapped { (schema: S.Schema): Mapped1 // TODO: constrain on Encoded2 having to contain only fields that fit Encoded (schema: S.Schema): Mapped2 } -export interface MM { +export interface MM { (schema: S.Schema): Effect, never, Repo> // TODO: constrain on Encoded2 having to contain only fields that fit Encoded (schema: S.Schema): Effect, never, Repo> diff --git a/packages/infra/src/Model/Repository/makeRepo.ts b/packages/infra/src/Model/Repository/makeRepo.ts index b542fce7b..b40586d4f 100644 --- a/packages/infra/src/Model/Repository/makeRepo.ts +++ b/packages/infra/src/Model/Repository/makeRepo.ts @@ -10,15 +10,14 @@ import type {} from "effect/Hash" import type { Context, NonEmptyReadonlyArray, S } from "effect-app" import { Effect } from "effect-app" import type { StoreConfig, StoreMaker } from "../../Store.js" +import type { FieldValues } from "../filter/types.js" import type { ExtendedRepository } from "./ext.js" import { extendRepo } from "./ext.js" import { makeRepoInternal } from "./internal/internal.js" export interface RepositoryOptions< - IdKey extends keyof T, - Encoded extends { - id: string - }, + IdKey extends keyof T & keyof Encoded, + Encoded, T, Evt = never, RPublish = never, @@ -66,9 +65,9 @@ export const makeRepo: { < ItemType extends string, RSchema, - Encoded extends { id: string }, + Encoded extends FieldValues, T, - IdKey extends keyof T, + IdKey extends keyof T & keyof Encoded, E = never, Evt = never, RInitial = never, @@ -86,7 +85,7 @@ export const makeRepo: { < ItemType extends string, RSchema, - Encoded extends { id: string }, + Encoded extends FieldValues, T extends { id: unknown }, E = never, Evt = never, @@ -105,9 +104,9 @@ export const makeRepo: { } = < ItemType extends string, R, - Encoded extends { id: string }, + Encoded extends FieldValues, T, - IdKey extends keyof T, + IdKey extends keyof T & keyof Encoded, E = never, RInitial = never, RPublish = never, diff --git a/packages/infra/src/Model/Repository/service.ts b/packages/infra/src/Model/Repository/service.ts index dccb101fc..750de6206 100644 --- a/packages/infra/src/Model/Repository/service.ts +++ b/packages/infra/src/Model/Repository/service.ts @@ -11,7 +11,7 @@ import type { Mapped } from "./legacy.js" */ export interface Repository< T, - Encoded extends { id: string }, + Encoded extends FieldValues, Evt, ItemType extends string, IdKey extends keyof T, diff --git a/packages/infra/src/Store/Cosmos.ts b/packages/infra/src/Store/Cosmos.ts index e68a07ebb..fa12e1304 100644 --- a/packages/infra/src/Store/Cosmos.ts +++ b/packages/infra/src/Store/Cosmos.ts @@ -7,10 +7,21 @@ import { dropUndefinedT } from "effect-app/utils" import { CosmosClient, CosmosClientLayer } from "../adapters/cosmos-client.js" import { OptimisticConcurrencyException } from "../errors.js" import { InfraLogger } from "../logger.js" +import type { FieldValues } from "../Model/filter/types.js" import { buildWhereCosmosQuery3, logQuery } from "./Cosmos/query.js" import { StoreMaker } from "./service.js" import type { FilterArgs, PersistenceModelType, StorageConfig, Store, StoreConfig } from "./service.js" +const makeMapId = + (idKey: IdKey) => ({ [idKey]: id, ...e }: Encoded) => ({ + ...e, + id + }) +const makeReverseMapId = + (idKey: IdKey) => + ({ id, ...t }: PersistenceModelType & { id: string }>) => + ({ ...t, [idKey]: id }) as any as PersistenceModelType + class CosmosDbOperationError { constructor(readonly message: string) {} } // TODO: Retry operation when running into RU limit. @@ -19,13 +30,17 @@ function makeCosmosStore({ prefix }: StorageConfig) { return Effect.gen(function*() { const { db } = yield* CosmosClient return { - make: & { id: Id }, R = never, E = never>( + make: ( name: string, + idKey: IdKey, seed?: Effect, E, R>, config?: StoreConfig ) => Effect.gen(function*() { + const mapId = makeMapId(idKey) + const mapReverseId = makeReverseMapId(idKey) type PM = PersistenceModelType + type PMCosmos = PersistenceModelType & { id: string }> const containerId = `${prefix}${name}` yield* Effect.promise(() => db.containers.createIfNotExists(dropUndefinedT({ @@ -47,34 +62,37 @@ function makeCosmosStore({ prefix }: StorageConfig) { .gen(function*() { // TODO: disable batching if need atomicity // we delay and batch to keep low amount of RUs - const b = [...items].map( - (x) => - [ - x, - Option.match(Option.fromNullable(x._etag), { - onNone: () => - dropUndefinedT({ - operationType: "Create" as const, - resourceBody: { - ...Struct.omit(x, "_etag"), - _partitionKey: config?.partitionValue(x) - }, - partitionKey: config?.partitionValue(x) - }), - onSome: (eTag) => - dropUndefinedT({ - operationType: "Replace" as const, - id: x.id, - resourceBody: { - ...Struct.omit(x, "_etag"), - _partitionKey: config?.partitionValue(x) - }, - ifMatch: eTag, - partitionKey: config?.partitionValue(x) - }) - }) - ] as const - ) + const b = [...items] + .map( + (x) => + [ + x, + Option.match(Option.fromNullable(x._etag), { + onNone: () => + dropUndefinedT({ + operationType: "Create" as const, + resourceBody: { + ...Struct.omit(x, "_etag", idKey), + id: x[idKey], + _partitionKey: config?.partitionValue(x) + }, + partitionKey: config?.partitionValue(x) + }), + onSome: (eTag) => + dropUndefinedT({ + operationType: "Replace" as const, + id: x[idKey], + resourceBody: { + ...Struct.omit(x, "_etag", idKey), + id: x[idKey], + _partitionKey: config?.partitionValue(x) + }, + ifMatch: eTag, + partitionKey: config?.partitionValue(x) + }) + }) + ] as const + ) const batches = Chunk.toReadonlyArray(Array.chunk_(b, config?.maxBulkSize ?? 10)) const batchResult = yield* Effect.forEach( @@ -135,15 +153,17 @@ function makeCosmosStore({ prefix }: StorageConfig) { onNone: () => ({ operationType: "Create" as const, resourceBody: { - ...Struct.omit(x, "_etag"), + ...Struct.omit(x, "_etag", idKey), + id: x[idKey], _partitionKey: config?.partitionValue(x) } }), onSome: (eTag) => ({ operationType: "Replace" as const, - id: x.id, + id: x[idKey], resourceBody: { - ...Struct.omit(x, "_etag"), + ...Struct.omit(x, "_etag", idKey), + id: x[idKey], _partitionKey: config?.partitionValue(x) }, ifMatch: eTag @@ -173,8 +193,9 @@ function makeCosmosStore({ prefix }: StorageConfig) { ) } - return batch.map(([e], i) => ({ + return batch.map(([{ id, ...e }], i) => ({ ...e, + [idKey]: id, _etag: result[i]?.eTag })) as unknown as NonEmptyReadonlyArray }) @@ -187,7 +208,7 @@ function makeCosmosStore({ prefix }: StorageConfig) { })) } - const s: Store = { + const s: Store = { all: Effect .sync(() => ({ query: `SELECT * FROM ${name} f WHERE f.id != @id`, @@ -199,9 +220,13 @@ function makeCosmosStore({ prefix }: StorageConfig) { Effect.promise(() => container .items - .query(q) + .query(q) .fetchAll() - .then(({ resources }) => resources.map((_) => ({ ...defaultValues, ..._ }))) + .then(({ resources }) => + resources.map( + (_) => ({ ...defaultValues, ...mapReverseId(_) }) + ) + ) ) ), Effect @@ -223,6 +248,7 @@ function makeCosmosStore({ prefix }: StorageConfig) { return Effect .sync(() => buildWhereCosmosQuery3( + idKey, filter ?? [], name, importedMarkerId, @@ -244,13 +270,20 @@ function makeCosmosStore({ prefix }: StorageConfig) { .query(q) .fetchAll() .then(({ resources }) => - resources.map((_) => ({ ...pipe(defaultValues, Struct.pick(...f.select!)), ..._ })) + resources.map((_) => + ({ + ...pipe(defaultValues, Struct.pick(...f.select!)), + ...mapReverseId(_ as any) + }) as any + ) ) : container .items .query<{ f: M }>(q) .fetchAll() - .then(({ resources }) => resources.map((_) => ({ ...defaultValues, ..._.f }))) + .then(({ resources }) => + resources.map(({ f }) => ({ ...defaultValues, ...mapReverseId(f as any) }) as any) + ) ) ) ) @@ -263,10 +296,10 @@ function makeCosmosStore({ prefix }: StorageConfig) { Effect .promise(() => container - .item(id, config?.partitionValue({ id } as Encoded)) + .item(id, config?.partitionValue({ [idKey]: id } as Encoded)) .read() .then(({ resource }) => - Option.fromNullable(resource).pipe(Option.map((_) => ({ ...defaultValues, ..._ }))) + Option.fromNullable(resource).pipe(Option.map((_) => ({ ...defaultValues, ...mapReverseId(_) }))) ) ) .pipe(Effect @@ -275,7 +308,7 @@ function makeCosmosStore({ prefix }: StorageConfig) { attributes: { "repository.container_id": containerId, "repository.model_name": name, - partitionValue: config?.partitionValue({ id } as Encoded), + partitionValue: config?.partitionValue({ [idKey]: id } as Encoded), id } })), @@ -288,14 +321,14 @@ function makeCosmosStore({ prefix }: StorageConfig) { onNone: () => Effect.promise(() => container.items.create({ - ...e, + ...mapId(e), _partitionKey: config?.partitionValue(e) }) ), onSome: (eTag) => Effect.promise(() => - container.item(e.id, config?.partitionValue(e)).replace( - { ...e, _partitionKey: config?.partitionValue(e) }, + container.item(e[idKey], config?.partitionValue(e)).replace( + { ...mapId(e), _partitionKey: config?.partitionValue(e) }, { accessCondition: { type: "IfMatch", @@ -310,7 +343,7 @@ function makeCosmosStore({ prefix }: StorageConfig) { Effect .flatMap((x) => { if (x.statusCode === 412 || x.statusCode === 404) { - return new OptimisticConcurrencyException({ type: name, id: e.id }) + return new OptimisticConcurrencyException({ type: name, id: e[idKey] }) } if (x.statusCode > 299 || x.statusCode < 200) { return Effect.die( @@ -327,18 +360,22 @@ function makeCosmosStore({ prefix }: StorageConfig) { Effect .withSpan("Cosmos.set [effect-app/infra/Store]", { captureStackTrace: false, - attributes: { "repository.container_id": containerId, "repository.model_name": name, id: e.id } + attributes: { + "repository.container_id": containerId, + "repository.model_name": name, + id: e[idKey] + } }) ), batchSet, bulkSet, remove: (e: Encoded) => Effect - .promise(() => container.item(e.id, config?.partitionValue(e)).delete()) + .promise(() => container.item(e[idKey], config?.partitionValue(e)).delete()) .pipe(Effect .withSpan("Cosmos.remove [effect-app/infra/Store]", { captureStackTrace: false, - attributes: { "repository.container_id": containerId, "repository.model_name": name, id: e.id } + attributes: { "repository.container_id": containerId, "repository.model_name": name, id: e[idKey] } })) } diff --git a/packages/infra/src/Store/Cosmos/query.ts b/packages/infra/src/Store/Cosmos/query.ts index fc3c555f3..12bdb2969 100644 --- a/packages/infra/src/Store/Cosmos/query.ts +++ b/packages/infra/src/Store/Cosmos/query.ts @@ -32,6 +32,7 @@ const arrayContains = (v: any[]) => v.map((_) => JSON.stringify(_)).join(", ") const vAsArr = (v: string) => v as unknown as any[] export function buildWhereCosmosQuery3( + idKey: PropertyKey, filter: readonly FilterResult[], name: string, importedMarkerId: string, @@ -42,10 +43,14 @@ export function buildWhereCosmosQuery3( limit?: number ) { const statement = (x: FilterR, i: number) => { + if (x.path === idKey) { + x = { ...x, path: "id" } + } let k = x.path.includes(".-1.") ? `${x.path.split(".-1.")[0]}.${x.path.split(".-1.")[1]!}` : `f.${x.path}` + // would have to map id, but shouldnt allow id in defaultValues anyway.. k = x.path in defaultValues ? `(${k} ?? ${JSON.stringify(defaultValues[x.path])})` : k const v = "@v" + i @@ -180,7 +185,7 @@ export function buildWhereCosmosQuery3( query: ` SELECT ${ select - ? `${select.map((_) => `f.${_}`).join(", ")}` + ? `${select.map((_) => (_ as any) === idKey ? "id" : _).map((_) => `f.${_}`).join(", ")}` : "f" } FROM ${name} f diff --git a/packages/infra/src/Store/Disk.ts b/packages/infra/src/Store/Disk.ts index 613de159f..06d738898 100644 --- a/packages/infra/src/Store/Disk.ts +++ b/packages/infra/src/Store/Disk.ts @@ -4,12 +4,14 @@ import * as fu from "../fileUtil.js" import fs from "fs" import { Console, Effect, FiberRef, flow } from "effect-app" +import type { FieldValues } from "../Model/filter/types.js" import { makeMemoryStoreInt, storeId } from "./Memory.js" import type { PersistenceModelType, StorageConfig, Store, StoreConfig } from "./service.js" import { StoreMaker } from "./service.js" -function makeDiskStoreInt( +function makeDiskStoreInt( prefix: string, + idKey: IdKey, namespace: string, dir: string, name: string, @@ -68,8 +70,9 @@ function makeDiskStoreInt( ) } - const store = yield* makeMemoryStoreInt( + const store = yield* makeMemoryStoreInt( name, + idKey, namespace, !fs.existsSync(file) ? seed @@ -107,7 +110,7 @@ function makeDiskStoreInt( store.remove, Effect.tap(flushToDiskInBackground) ) - } satisfies Store + } satisfies Store }) } @@ -121,15 +124,16 @@ export function makeDiskStore({ prefix }: StorageConfig, dir: string) { fs.mkdirSync(dir) } return { - make: ( + make: ( name: string, + idKey: IdKey, seed?: Effect, E, R>, config?: StoreConfig ) => Effect.gen(function*() { const storesSem = Effect.unsafeMakeSemaphore(1) - const primary = yield* makeDiskStoreInt(prefix, "primary", dir, name, seed, config?.defaultValues) - const stores = new Map>([["primary", primary]]) + const primary = yield* makeDiskStoreInt(prefix, idKey, "primary", dir, name, seed, config?.defaultValues) + const stores = new Map>([["primary", primary]]) const ctx = yield* Effect.context() const getStore = !config?.allowNamespace ? Effect.succeed(primary) @@ -145,7 +149,15 @@ export function makeDiskStore({ prefix }: StorageConfig, dir: string) { Effect.suspend(() => { const existing = stores.get(namespace) if (existing) return Effect.sync(() => existing) - return makeDiskStoreInt(prefix, namespace, dir, name, seed, config?.defaultValues) + return makeDiskStoreInt( + prefix, + idKey, + namespace, + dir, + name, + seed, + config?.defaultValues + ) .pipe( Effect.orDie, Effect.provide(ctx), @@ -155,7 +167,7 @@ export function makeDiskStore({ prefix }: StorageConfig, dir: string) { ) })) - const s: Store = { + const s: Store = { all: Effect.flatMap(getStore, (_) => _.all), find: (...args) => Effect.flatMap(getStore, (_) => _.find(...args)), filter: (...args) => Effect.flatMap(getStore, (_) => _.filter(...args)), diff --git a/packages/infra/src/Store/Memory.ts b/packages/infra/src/Store/Memory.ts index 4cdafee2a..320278685 100644 --- a/packages/infra/src/Store/Memory.ts +++ b/packages/infra/src/Store/Memory.ts @@ -5,12 +5,13 @@ import type { NonEmptyReadonlyArray } from "effect-app" import { NonEmptyString255 } from "effect-app/Schema" import { get } from "effect-app/utils" import { InfraLogger } from "../logger.js" +import type { FieldValues } from "../Model/filter/types.js" import { codeFilter } from "./codeFilter.js" import type { FilterArgs, PersistenceModelType, Store, StoreConfig } from "./service.js" import { StoreMaker } from "./service.js" import { makeUpdateETag } from "./utils.js" -export function memFilter(f: FilterArgs) { +export function memFilter(f: FilterArgs) { type M = U extends undefined ? T : Pick return ((c: T[]): M[] => { const select = (r: T[]): M[] => (f.select ? r.map(Struct.pick(...f.select)) : r) as any @@ -76,8 +77,9 @@ function logQuery(f: FilterArgs, defaultValues?: any) { })) } -export function makeMemoryStoreInt( +export function makeMemoryStoreInt( modelName: string, + idKey: IdKey, namespace: string, seed?: Effect, E, R>, _defaultValues?: Partial @@ -88,8 +90,8 @@ export function makeMemoryStoreInt []) const defaultValues = _defaultValues ?? {} - const items = new Map([...items_].map((_) => [_.id, { _etag: undefined, ...defaultValues, ..._ }] as const)) - const store = Ref.unsafeMake>(items) + const items = new Map([...items_].map((_) => [_[idKey], { _etag: undefined, ...defaultValues, ..._ }] as const)) + const store = Ref.unsafeMake>(items) const sem = Effect.unsafeMakeSemaphore(1) const withPermit = sem.withPermits(1) const values = Effect.map(Ref.get(store), (s) => s.values()) @@ -98,7 +100,7 @@ export function makeMemoryStoreInt) => Effect - .forEach(items, (i) => Effect.flatMap(s.find(i.id), (current) => updateETag(i, current))) + .forEach(items, (i) => Effect.flatMap(s.find(i[idKey]), (current) => updateETag(i, idKey, current))) .pipe( Effect .tap((items) => @@ -107,8 +109,8 @@ export function makeMemoryStoreInt { - const mut = m as Map - items.forEach((e) => mut.set(e.id, e)) + const mut = m as Map + items.forEach((e) => mut.set(e[idKey], e)) return mut }), Effect @@ -119,7 +121,7 @@ export function makeMemoryStoreInt _), withPermit ) - const s: Store = { + const s: Store = { all: all.pipe(Effect.withSpan("Memory.all [effect-app/infra/Store]", { captureStackTrace: false, attributes: { @@ -153,13 +155,13 @@ export function makeMemoryStoreInt s - .find(e.id) + .find(e[idKey]) .pipe( - Effect.flatMap((current) => updateETag(e, current)), + Effect.flatMap((current) => updateETag(e, idKey, current)), Effect .tap((e) => Ref.get(store).pipe( - Effect.map((_) => new Map([..._, [e.id, e]])), + Effect.map((_) => new Map([..._, [e[idKey], e]])), Effect.flatMap((_) => Ref.set(store, _)) ) ), @@ -197,7 +199,7 @@ export function makeMemoryStoreInt new Map([..._].filter(([_]) => _ !== e.id))), + Effect.map((_) => new Map([..._].filter(([_]) => _ !== e[idKey]))), Effect.flatMap((_) => Ref.set(store, _)), withPermit, Effect.withSpan("Memory.remove [effect-app/infra/Store]", { @@ -211,14 +213,21 @@ export function makeMemoryStoreInt ({ - make: ( + make: ( modelName: string, + idKey: IdKey, seed?: Effect, E, R>, config?: StoreConfig ) => Effect.gen(function*() { const storesSem = Effect.unsafeMakeSemaphore(1) - const primary = yield* makeMemoryStoreInt(modelName, "primary", seed, config?.defaultValues) + const primary = yield* makeMemoryStoreInt( + modelName, + idKey, + "primary", + seed, + config?.defaultValues + ) const ctx = yield* Effect.context() const stores = new Map([["primary", primary]]) const getStore = !config?.allowNamespace @@ -234,7 +243,7 @@ export const makeMemoryStore = () => ({ return storesSem.withPermits(1)(Effect.suspend(() => { const store = stores.get(namespace) if (store) return Effect.sync(() => store) - return makeMemoryStoreInt(modelName, namespace, seed, config?.defaultValues) + return makeMemoryStoreInt(modelName, idKey, namespace, seed, config?.defaultValues) .pipe( Effect.orDie, Effect.provide(ctx), @@ -242,7 +251,7 @@ export const makeMemoryStore = () => ({ ) })) })) - const s: Store = { + const s: Store = { all: Effect.flatMap(getStore, (_) => _.all), find: (...args) => Effect.flatMap(getStore, (_) => _.find(...args)), filter: (...args) => Effect.flatMap(getStore, (_) => _.filter(...args)), diff --git a/packages/infra/src/Store/codeFilter.ts b/packages/infra/src/Store/codeFilter.ts index 8828d8b0a..0401a9b35 100644 --- a/packages/infra/src/Store/codeFilter.ts +++ b/packages/infra/src/Store/codeFilter.ts @@ -3,6 +3,7 @@ import { Array, Option } from "effect-app" import { assertUnreachable, get } from "effect-app/utils" import type { FilterR, FilterResult } from "../Model/filter/filterApi.js" +import type { FieldValues } from "../Model/filter/types.js" import type { Filter } from "./service.js" import { compare, greaterThan, greaterThanExclusive, lowerThan, lowerThanExclusive } from "./utils.js" @@ -120,6 +121,6 @@ export const codeFilter3_ = (state: readonly FilterResult[], sut: E): boolean return eval(s) } -export function codeFilter(filter: Filter) { +export function codeFilter(filter: Filter) { return (x: E) => codeFilter3_(filter, x) ? Option.some(x as unknown as NE) : Option.none() } diff --git a/packages/infra/src/Store/service.ts b/packages/infra/src/Store/service.ts index a7bbe8d7b..495123032 100644 --- a/packages/infra/src/Store/service.ts +++ b/packages/infra/src/Store/service.ts @@ -57,7 +57,7 @@ export interface O { direction: "ASC" | "DESC" } -export interface FilterArgs { +export interface FilterArgs { t: Encoded filter?: Filter | undefined select?: NonEmptyReadonlyArray | undefined @@ -66,18 +66,18 @@ export interface FilterArgs = ( +export type FilterFunc = ( args: FilterArgs ) => Effect<(U extends undefined ? Encoded : Pick)[]> export interface Store< - Encoded extends { id: Id }, - Id extends string, + IdKey extends keyof Encoded, + Encoded extends FieldValues, PM extends PersistenceModelType = PersistenceModelType > { all: Effect filter: FilterFunc - find: (id: Id) => Effect> + find: (id: Encoded[IdKey]) => Effect> set: (e: PM) => Effect batchSet: ( items: NonEmptyReadonlyArray @@ -96,11 +96,12 @@ export interface Store< * @tsplus companion StoreMaker.Ops */ export class StoreMaker extends Context.TagId("effect-app/StoreMaker")( + make: ( name: string, + idKey: IdKey, seed?: Effect, E, R>, config?: StoreConfig - ) => Effect, E, R> + ) => Effect, E, R> }>() { } diff --git a/packages/infra/src/Store/utils.ts b/packages/infra/src/Store/utils.ts index 861fcc42f..1b46766ee 100644 --- a/packages/infra/src/Store/utils.ts +++ b/packages/infra/src/Store/utils.ts @@ -13,18 +13,19 @@ export const makeETag = >( }) as any export const makeUpdateETag = - (type: string) => >(e: E, current: Option) => + (type: string) => + >(e: E, idKey: IdKey, current: Option) => Effect.gen(function*() { if (e._etag) { yield* Effect.mapError( current, - () => new OptimisticConcurrencyException({ type, id: e.id, current: "", found: e._etag }) + () => new OptimisticConcurrencyException({ type, id: e[idKey] as string, current: "", found: e._etag }) ) } if (Option.isSome(current) && current.value._etag !== e._etag) { return yield* new OptimisticConcurrencyException({ type, - id: current.value.id, + id: current.value[idKey] as string, current: current.value._etag, found: e._etag })