import { Effect, Stream } from 'effect'; import { nextUlid, type DecodedEntity, type EncodedEntity, } from '../../../core/index.js'; import type { AnyEntityESchema } from '../../../eschema/index.js'; import { CheckRefused, DatabaseError, ItemAlreadyExists, NoItemToCheck, NoItemToUpdate, PrimaryKeyUpdateNotSupported, } from '../error/index.js'; import { StdTableService, ConditionFailure, type JsonObject, type ContractFailure, type EncodedData, type EncodedItem, type EncodedKey, type ItemCondition, } from '../contract/index.js'; import type { AccessPatternMap, KeyedEntityDefinition, } from '../definition/index.js'; import { queryEntity } from './query.js'; import { broadcast, dbError, failReason, subscribe } from './effects.js'; import type { CheckOp, EntityInvariant, EntityKey, EntityValue, InsertValue, KeyedEntity, TransactOp, QueryOptions, UpdateInput, UpdateValue, WriteOptions, } from './entity.js'; import { decode, derivedKey, encode, entityResult, makeEncodedItem, } from './storage.js'; export const makeKeyedEntity = < Name extends string, S extends AnyEntityESchema, Pk extends readonly string[], Patterns extends AccessPatternMap, >( definition: KeyedEntityDefinition< Name, S, Pk, readonly [S['idField']], Patterns >, ): KeyedEntity => { const service = StdTableService(definition.table.logicalName); const readItem = (key: EncodedKey, consistent?: boolean) => Effect.gen(function* () { const contract = (yield* service).contract; return yield* contract .getItem(key, consistent === undefined ? undefined : { consistent }) .pipe( Effect.mapError((error) => dbError('get', error as ContractFailure, definition.name), ), ); }); const readRaw = (key: JsonObject, consistent?: boolean) => readItem(derivedKey(definition, key), consistent); const decodeCurrent = (item: EncodedItem) => decode(definition.schema, item) as Effect.Effect< DecodedEntity>, DatabaseError >; const read = (key: EntityKey) => readRaw(key as object as JsonObject).pipe( Effect.flatMap((item) => item === null ? Effect.succeed(null) : (decodeCurrent(item) as Effect.Effect< DecodedEntity> | null, DatabaseError >), ), ); const put = ( encoded: EncodedEntity, value: EntityValue, deleted: boolean, version: string, condition: ItemCondition | undefined, ) => { const item = makeEncodedItem(definition, encoded, version, deleted); return { write: { kind: 'put' as const, item, ...(condition === undefined ? {} : { condition }), }, entity: entityResult(item, value) as DecodedEntity>, }; }; const applyUpdate = ( kind: 'updateOp' | 'deleteOp' | 'restoreOp', update: UpdateInput, options: WriteOptions> | undefined, current: EncodedItem | null, version: string, ) => Effect.gen(function* () { if (current === null) return yield* failReason( new NoItemToUpdate({ entity: definition.name }), ); const existing = yield* decodeCurrent(current); const check = options?.check; if (check !== undefined && check(existing.value) !== true) return yield* failReason(new CheckRefused({ entity: definition.name })); const deleted = kind === 'deleteOp' ? true : kind === 'restoreOp' ? false : current.meta._d; const partial = kind === 'updateOp' ? typeof update === 'function' ? update(existing.value) : update : ({} as UpdateValue); const value = { ...existing.value, ...partial } as EntityValue; const changedPrimaryFields = [ ...definition.primary.pk, ...definition.primary.sk, ].filter( (field) => existing.value[field as keyof EntityValue] !== value[field as keyof EntityValue], ); if (changedPrimaryFields.length > 0) return yield* failReason( new PrimaryKeyUpdateNotSupported({ entity: definition.name, fields: changedPrimaryFields, }), ); const encoded = yield* encode(definition.schema, value, definition.name); return put( encoded, value, deleted, version, options?.lastWriteWins ? undefined : { kind: 'updated', value: current.meta._u }, ); }); const located = (key: JsonObject) => { const derived = derivedKey(definition, key); return { key: derived, target: `${derived.pk}\0${derived.sk}` }; }; const insertOp = (value: InsertValue) => Effect.gen(function* () { const full = value as object as EntityValue; const encoded = yield* encode(definition.schema, full, definition.name); const item = makeEncodedItem(definition, encoded, '', false); return { tableName: definition.table.logicalName, entityName: definition.name, key: { pk: item.pk, sk: item.sk }, target: `${item.pk}\0${item.sk}`, readsCurrent: false, operationKind: 'insertOp' as const, apply: (_current: EncodedItem | null, version: string) => Effect.succeed( put(encoded, full, false, version, { kind: 'not-exists' }), ), } as TransactOp>; }); const updateOp = ( kind: 'updateOp' | 'deleteOp' | 'restoreOp', key: EntityKey, update: UpdateInput, options?: WriteOptions>, ) => Effect.sync((): TransactOp> => ({ tableName: definition.table.logicalName, entityName: definition.name, ...located(key as object as JsonObject), readsCurrent: true, operationKind: kind, apply: (current, version) => applyUpdate(kind, update, options, current, version), })); const conditionOp = (value: JsonObject, condition: ItemCondition) => Effect.sync((): CheckOp => { const at = located(value); return { tableName: definition.table.logicalName, entityName: definition.name, ...at, readsCurrent: false, operationKind: 'checkOp', apply: () => Effect.succeed({ write: { kind: 'check' as const, key: at.key, condition }, entity: null, }), }; }); const getAndCheckOp = ( key: EntityKey, check: EntityInvariant>, ) => Effect.sync((): CheckOp => { const at = located(key as object as JsonObject); return { tableName: definition.table.logicalName, entityName: definition.name, ...at, readsCurrent: true, operationKind: 'checkOp', apply: (current) => Effect.gen(function* () { if (current === null || current.meta._d) return yield* failReason( new NoItemToCheck({ entity: definition.name }), ); const existing = yield* decodeCurrent(current); if (check(existing.value) !== true) return yield* failReason( new CheckRefused({ entity: definition.name }), ); return { write: { kind: 'check' as const, key: at.key, condition: { kind: 'updated' as const, value: current.meta._u, }, }, entity: null, }; }), }; }); const runOne = (operation: string, op: TransactOp>) => Effect.gen(function* () { const current = op.readsCurrent ? yield* readItem(op.key, true) : null; const version = yield* nextUlid; const applied = yield* op.apply(current, version); const contract = (yield* service).contract; yield* contract.writeItem(applied.write).pipe( Effect.mapError((error) => { if (operation === 'insert' && error instanceof ConditionFailure) return new DatabaseError({ reason: new ItemAlreadyExists({ entity: definition.name }), }); return dbError(operation, error as ContractFailure, definition.name); }), ); yield* broadcast(applied.entity); return applied.entity as DecodedEntity>; }); const runWithRetry = ( operation: string, op: TransactOp>, retries: number, ) => Effect.gen(function* () { for (let attempt = 0; ; attempt++) { const result = yield* runOne(operation, op).pipe(Effect.result); if (result._tag === 'Success') return result.success; if ( result.failure.reason._tag !== 'ConditionFailed' || attempt >= retries ) return yield* Effect.fail(result.failure); } }); const spanned = (op: string, key?: EntityKey) => (effect: Effect.Effect) => Effect.withSpan(effect, `StdTable.${op}`, { attributes: { entity: definition.name, ...key }, }); const entity = { ...definition, get: (key: EntityKey, options?: { excludeDeleted?: boolean }) => read(key).pipe( Effect.map((result) => options?.excludeDeleted && result?.meta._d ? null : result, ), spanned('get', key), ), insert: (value: InsertValue) => insertOp(value).pipe( Effect.flatMap((op) => runOne('insert', op)), spanned('insert'), ), insertOp, getAndUpdate: ( key: EntityKey, update: UpdateInput, options?: WriteOptions> & { retries?: number }, ) => updateOp('updateOp', key, update, options).pipe( Effect.flatMap((op) => runWithRetry('getAndUpdate', op, options?.retries ?? 3), ), spanned('getAndUpdate', key), ), getAndUpdateOp: ( key: EntityKey, update: UpdateInput, options?: WriteOptions>, ) => updateOp('updateOp', key, update, options), getAndCheckOp, delete: (key: EntityKey, options?: WriteOptions>) => updateOp('deleteOp', key, {}, options).pipe( Effect.flatMap((op) => runOne('delete', op)), spanned('delete', key), ), deleteOp: (key: EntityKey, options?: WriteOptions>) => updateOp('deleteOp', key, {}, options), restore: (key: EntityKey, options?: WriteOptions>) => updateOp('restoreOp', key, {}, options).pipe( Effect.flatMap((op) => runOne('restore', op)), spanned('restore', key), ), restoreOp: ( key: EntityKey, options?: WriteOptions>, ) => updateOp('restoreOp', key, {}, options), unchangedOp: (entity: DecodedEntity>) => conditionOp(entity.value as object as JsonObject, { kind: 'updated', value: entity.meta._u, }), existsOp: (key: EntityKey) => conditionOp(key as object as JsonObject, { kind: 'exists' }), notExistsOp: (key: EntityKey) => conditionOp(key as object as JsonObject, { kind: 'not-exists' }), hardDelete: ( key: EntityKey, _confirmation: 'I KNOW WHAT I AM DOING', ) => Effect.gen(function* () { const existing = yield* read(key); if (existing === null) return yield* failReason( new NoItemToUpdate({ entity: definition.name }), ); const contract = (yield* service).contract; yield* contract .hardDeleteItem(derivedKey(definition, key as object as JsonObject)) .pipe( Effect.mapError((error) => dbError('hardDelete', error as ContractFailure, definition.name), ), ); const deleted: DecodedEntity> = { value: existing.value as EntityValue, meta: { ...existing.meta, _d: true }, }; yield* broadcast(deleted); return deleted; }).pipe(spanned('hardDelete', key)), dangerouslyRemoveAllItems: (_confirmation: 'I KNOW WHAT I AM DOING') => Effect.gen(function* () { const contract = (yield* service).contract; const itemsDeleted = yield* contract .hardDeleteEntityItems(definition.name) .pipe( Effect.mapError((error) => dbError( 'dangerouslyRemoveAllItems', error as ContractFailure, definition.name, ), ), ); return { itemsDeleted }; }), query: ( patternName: keyof Patterns & string, input: JsonObject, options?: QueryOptions>, ) => Effect.withSpan( queryEntity(definition, patternName, input, options), 'StdTable.query', { attributes: { entity: definition.name, pattern: patternName } }, ), subscribe: (filter?: Partial>) => subscribe>(definition.name, filter).pipe( Stream.withSpan('StdTable.subscribe', { attributes: { entity: definition.name }, }), ), }; return entity as unknown as KeyedEntity; };