import { Observable, ObservableInput } from 'rxjs' import shallowEqual from 'shallowequal' import { isPlainObject } from 'is-plain-object' import { DomainConceptName } from './type' export type SerializablePrimitives = void | undefined | number | string | boolean | null export type ReadonlySerializableArray = readonly Serializable[] export type SerializableArray = Serializable[] | ReadonlySerializableArray export type SerializableObject = { [key: string]: Serializable } export type Serializable = SerializablePrimitives | SerializableArray | SerializableObject export type ToType = T extends object | unknown[] ? { [key in keyof T]: ToType } : T export type Args = [] | [arg: T] | [arg?: T] export type RemeshInjectedContext = { get, U>(input: RemeshQueryAction): U get(input: RemeshStateItem): state get(input: RemeshStateItem | RemeshQueryAction): unknown fromEvent: (Event: RemeshEvent | RemeshSubscribeOnlyEvent) => Observable fromQuery: , U>(Query: RemeshQueryAction) => Observable } export type RemeshEventContext = { get: RemeshInjectedContext['get'] } export type RemeshEvent = { type: 'RemeshEvent' eventId: number eventName: DomainConceptName<'Event'> impl?: (context: RemeshEventContext, arg: T[0]) => U (...args: T): RemeshEventAction emitted: (task: RemeshCommandTask) => void owner: RemeshDomainAction inspectable: boolean toSubscribeOnlyEvent: () => RemeshSubscribeOnlyEvent } export type RemeshEventAction = { type: 'RemeshEventAction' arg: T[0] Event: RemeshEvent } export type RemeshEventOptions = { name: DomainConceptName<'Event'> inspectable?: boolean impl: (context: RemeshEventContext, ...args: T) => U } let eventUid = 0 export function RemeshEvent(options: RemeshEventOptions): RemeshEvent export function RemeshEvent(options: Omit, 'impl'>): RemeshEvent<[T], T> export function RemeshEvent( options: RemeshEventOptions | Omit, 'impl'>, ): RemeshEvent { const eventId = eventUid++ const Event = ((arg: T[0]): RemeshEventAction => { return { type: 'RemeshEventAction', arg, Event, } }) as unknown as RemeshEvent Event.type = 'RemeshEvent' Event.eventId = eventId Event.eventName = options.name Event.owner = DefaultDomain() Event.inspectable = 'inspectable' in options ? options.inspectable ?? true : true Event.toSubscribeOnlyEvent = () => { return toRemeshSubscribeOnlyEvent(Event) } if ('impl' in options) { Event.impl = options.impl as unknown as typeof Event.impl } Event.emitted = (task) => { if (remeshCommandTaskStore.emitted.has(Event)) { return remeshCommandTaskStore.emitted.get(Event)!.add(task) } const taskSet = new Set>() remeshCommandTaskStore.emitted.set(Event, taskSet) taskSet.add(task) return taskSet } return Event } export type RemeshSubscribeOnlyEvent<_T, _U> = { type: 'RemeshSubscribeOnlyEvent' eventId: number eventName: DomainConceptName<'Event'> } export type ToRemeshSubscribeOnlyEvent = T extends RemeshSubscribeOnlyEvent ? T : T extends RemeshEvent ? RemeshSubscribeOnlyEvent : never export type ToRemeshSubscribeOnlyEventMap = { [K in keyof T]: ToRemeshSubscribeOnlyEvent } const eventToSubscribeOnlyEventWeakMap = new WeakMap, RemeshSubscribeOnlyEvent>() const subscribeOnlyEventToEventWeakMap = new WeakMap, RemeshEvent>() export const toRemeshSubscribeOnlyEvent = (event: RemeshEvent): RemeshSubscribeOnlyEvent => { const subscribeOnlyEvent = eventToSubscribeOnlyEventWeakMap.get(event) if (subscribeOnlyEvent) { return subscribeOnlyEvent } const newSubscribeOnlyEvent = { type: 'RemeshSubscribeOnlyEvent', eventId: event.eventId, eventName: event.eventName, } as RemeshSubscribeOnlyEvent eventToSubscribeOnlyEventWeakMap.set(event, newSubscribeOnlyEvent) subscribeOnlyEventToEventWeakMap.set(newSubscribeOnlyEvent, event) return newSubscribeOnlyEvent } export const internalToOriginalEvent = ( subscribeOnlyEvent: RemeshSubscribeOnlyEvent, ): RemeshEvent => { const event = subscribeOnlyEventToEventWeakMap.get(subscribeOnlyEvent) if (event) { return event } throw new Error(`SubscribeOnlyEvent ${subscribeOnlyEvent.eventName} does not have an associated Event`) } export type CompareFn = (prev: T, curr: T) => boolean export const DefaultStateValue = Symbol('DefaultStateValue') export type DefaultStateValue = typeof DefaultStateValue export type RemeshState = { type: 'RemeshState' stateId: number stateName: DomainConceptName<'State'> (): RemeshStateItem default: T | (() => T) | DefaultStateValue owner: RemeshDomainAction compare: CompareFn inspectable: boolean } export type RemeshStateItem = { type: 'RemeshStateItem' State: RemeshState new: (state: T) => RemeshStateItemUpdatePayload } export type RemeshStateItemUpdatePayload = { type: 'RemeshStateItemUpdatePayload' key?: string value: T stateItem: RemeshStateItem } export type RemeshStateOptions = { name: DomainConceptName<'State'> inspectable?: boolean compare?: CompareFn } & ( | { default: T | (() => T) } | { defer: true } ) let stateUid = 0 export const defaultCompare = (prev: T, curr: T) => { if (isPlainObject(prev) && isPlainObject(curr)) { return shallowEqual(prev, curr) } if (Array.isArray(prev) && Array.isArray(curr)) { return shallowEqual(prev, curr) } return prev === curr } export const RemeshState = (options: RemeshStateOptions): RemeshState => { const stateId = stateUid++ type StateItem = RemeshStateItem let cacheForNullary = null as StateItem | null const State = (() => { if (cacheForNullary) { return cacheForNullary } const stateItem: StateItem = { type: 'RemeshStateItem', State, new: (newState) => { return { type: 'RemeshStateItemUpdatePayload', value: newState, stateItem, } }, } cacheForNullary = stateItem return stateItem }) as RemeshState State.type = 'RemeshState' State.stateId = stateId State.stateName = options.name if ('default' in options) { State.default = options.default } else if (options.defer) { State.default = DefaultStateValue } State.compare = options.compare ?? defaultCompare State.owner = DefaultDomain() State.inspectable = options.inspectable ?? true return State } export type RemeshQueryContext = { get: RemeshInjectedContext['get'] } export type RemeshQuery, U> = { type: 'RemeshQuery' queryId: number queryName: DomainConceptName<'Query'> impl: (context: RemeshQueryContext, arg: T[0]) => U (...args: T): RemeshQueryAction owner: RemeshDomainAction onError?: (error: Error, value: U) => U compare: CompareFn changed: (task: RemeshCommandTask<[{ current: U; previous: U }]>) => void inspectable: boolean } export type RemeshQueryAction, U> = { type: 'RemeshQueryAction' Query: RemeshQuery arg: T[0] } export type RemeshQueryOptions, U> = { name: DomainConceptName<'Query'> inspectable?: boolean impl: (context: RemeshQueryContext, ...args: T) => U onError?: (error: Error, previous: U) => U compare?: RemeshQuery['compare'] } let queryUid = 0 export const RemeshQuery = , U>(options: RemeshQueryOptions): RemeshQuery => { const queryId = queryUid++ /** * optimize for nullary query */ let cacheForNullary: RemeshQueryAction | null = null const Query = ((arg: T[0]) => { if (arg === undefined && cacheForNullary) { return cacheForNullary } const action: RemeshQueryAction = { type: 'RemeshQueryAction', Query, arg, } if (arg === undefined) { cacheForNullary = action } return action }) as unknown as RemeshQuery Query.type = 'RemeshQuery' Query.queryId = queryId Query.queryName = options.name Query.impl = options.impl as (context: RemeshQueryContext, arg: T[0]) => U Query.compare = options.compare ?? defaultCompare Query.owner = DefaultDomain() Query.inspectable = options.inspectable ?? true Query.onError = options.onError Query.changed = (task) => { if (remeshCommandTaskStore.changed.has(Query)) { return remeshCommandTaskStore.changed.get(Query)!.add(task) } const taskSet = new Set>() remeshCommandTaskStore.changed.set(Query, taskSet) taskSet.add(task) return taskSet } return Query } export type RemeshCommandContext = { get: RemeshInjectedContext['get'] } export type RemeshCommandAction = { type: 'RemeshCommandAction' arg: T[0] Command: RemeshCommand } export type RemeshCommandOutput = | RemeshCommandAction | RemeshEventAction | RemeshStateItemUpdatePayload | RemeshCommandOutput[] | null export type RemeshCommand = { type: 'RemeshCommand' commandId: number commandName: DomainConceptName<'Command'> impl: (context: RemeshCommandContext, arg: T[0]) => RemeshCommandOutput (...args: T): RemeshCommandAction owner: RemeshDomainAction before: (task: RemeshCommandTask) => void after: (task: RemeshCommandTask) => void inspectable: boolean } type RemeshCommandTask = (context: RemeshCommandContext, ...args: T) => RemeshCommandOutput export type RemeshCommandOptions = { name: DomainConceptName<'Command'> inspectable?: boolean impl: (context: RemeshCommandContext, ...args: T) => RemeshCommandOutput } let commandUid = 0 const remeshCommandTaskStore = { before: new WeakMap, Set>>(), after: new WeakMap, Set>>(), changed: new WeakMap, Set>>(), emitted: new WeakMap, Set>>(), } export const getCommandTaskSet = (key: unknown, type: keyof typeof remeshCommandTaskStore) => { return remeshCommandTaskStore[type].get(key as any) } export const RemeshCommand = (options: RemeshCommandOptions): RemeshCommand => { const commandId = commandUid++ const Command = ((arg: T[0]) => { return { type: 'RemeshCommandAction', arg, Command, } }) as unknown as RemeshCommand Command.type = 'RemeshCommand' Command.commandId = commandId Command.commandName = options.name Command.impl = options.impl as RemeshCommand['impl'] Command.owner = DefaultDomain() Command.inspectable = options.inspectable ?? true Command.before = (task) => { if (remeshCommandTaskStore.before.has(Command)) { return remeshCommandTaskStore.before.get(Command)!.add(task) } const taskSet = new Set>() remeshCommandTaskStore.before.set(Command, taskSet) taskSet.add(task) return taskSet } Command.after = (task) => { if (remeshCommandTaskStore.after.has(Command)) { return remeshCommandTaskStore.after.get(Command)!.add(task) } const taskSet = new Set>() remeshCommandTaskStore.after.set(Command, taskSet) taskSet.add(task) return taskSet } return Command } export type RemeshExternImpl = { type: 'RemeshExternImpl' Extern: RemeshExtern value: T } export type RemeshExtern = { type: 'RemeshExtern' externId: number default: T impl(value: T): RemeshExternImpl } export type RemeshExternOptions = { default: RemeshExtern['default'] } let externUid = 0 export const RemeshExtern = (options: RemeshExternOptions): RemeshExtern => { const Extern: RemeshExtern = { type: 'RemeshExtern', externId: externUid++, default: options.default, impl: (value) => { return { type: 'RemeshExternImpl', Extern, value, } }, } return Extern } export type RemeshDomainPreloadQueryContext = { get: RemeshInjectedContext['get'] } export type RemeshDomainPreloadCommandContext = { get: RemeshInjectedContext['get'] } export type RemeshDomainPreloadCommandOutput = | RemeshStateItemUpdatePayload | RemeshDomainPreloadCommandOutput[] | null export type RemeshDomainPreloadOptions = { key: string query: (context: RemeshDomainPreloadQueryContext) => Promise command: (context: RemeshDomainPreloadCommandContext, data: T) => RemeshDomainPreloadCommandOutput } export type RemeshEffectContext = { get: RemeshInjectedContext['get'] fromEvent: RemeshInjectedContext['fromEvent'] fromQuery: RemeshInjectedContext['fromQuery'] } export type RemeshAction = RemeshEventAction | RemeshCommandAction | null | RemeshAction[] export type RemeshEffect = { name: DomainConceptName<'Effect'> impl: (context: RemeshEffectContext) => ObservableInput | null } export type RemeshDomainContext = { // definitions state: typeof RemeshState event: typeof RemeshEvent query: typeof RemeshQuery command: typeof RemeshCommand effect: (effect: RemeshEffect) => void preload: (options: RemeshDomainPreloadOptions) => void // methods getExtern: (Extern: RemeshExtern) => T getDomain: >( domainAction: RemeshDomainAction, ) => { [key in keyof VerifiedRemeshDomainDefinition]: VerifiedRemeshDomainDefinition[key] } forgetDomain: >( domainAction: RemeshDomainAction, ) => void } export type DomainTypeOf> = T extends RemeshDomain< infer Definition, any > ? VerifiedRemeshDomainDefinition : never export type RemeshEvents = { [key: string]: RemeshEvent | RemeshSubscribeOnlyEvent } export type RemeshQueries = { [key: string]: RemeshQuery } export type RemeshCommands = { [key: string]: RemeshCommand } export type RemeshDomainOutput = { event: RemeshEvents query: RemeshQueries command: RemeshCommands } export type RemeshDomainDefinition = Partial export type VerifiedRemeshDomainDefinition = Pick< { event: { [key in keyof T['event']]: key extends DomainConceptName<'Event'> ? ToRemeshSubscribeOnlyEvent : `${key & string} is not a valid event name` } query: { [key in keyof T['query']]: key extends DomainConceptName<'Query'> ? T['query'][key] : `${key & string} is not a valid query name` } command: { [key in keyof T['command']]: key extends DomainConceptName<'Command'> ? T['command'][key] : `${key & string} is not a valid command name` } }, ('event' | 'query' | 'command') & keyof T > export const toValidRemeshDomainDefinition = ( domainDefinition: T, ): VerifiedRemeshDomainDefinition => { const result = {} as VerifiedRemeshDomainDefinition if (domainDefinition.event) { const eventRecord = {} as VerifiedRemeshDomainDefinition['event'] for (const key in domainDefinition.event) { const event = domainDefinition.event[key] if (event.type === 'RemeshSubscribeOnlyEvent') { // @ts-ignore pass it eventRecord[key] = event } else { // @ts-ignore pass it eventRecord[key] = toRemeshSubscribeOnlyEvent(event) } } result.event = eventRecord } if (domainDefinition.query) { result.query = domainDefinition.query as unknown as typeof result.query } if (domainDefinition.command) { result.command = domainDefinition.command as unknown as typeof result.command } return result } export const RemeshModule = ( module: T, ): { [key in keyof VerifiedRemeshDomainDefinition]: VerifiedRemeshDomainDefinition[key] } => { return toValidRemeshDomainDefinition(module) } export type RemeshDomain> = { type: 'RemeshDomain' domainName: DomainConceptName<'Domain'> domainId: number impl: (context: RemeshDomainContext, arg: U[0]) => T (...args: U): RemeshDomainAction inspectable: boolean } export type RemeshDomainAction> = { type: 'RemeshDomainAction' Domain: RemeshDomain arg: U[0] } export type RemeshDomainOptions> = { name: DomainConceptName<'Domain'> inspectable?: boolean impl: (context: RemeshDomainContext, ...args: U) => T } let domainUid = 0 export const RemeshDomain = >( options: RemeshDomainOptions, ): RemeshDomain => { /** * optimize for nullary domain */ let cacheForNullary: RemeshDomainAction | null = null const Domain: RemeshDomain = ((arg: U[0]) => { if (arg === undefined && cacheForNullary) { return cacheForNullary } const result: RemeshDomainAction = { type: 'RemeshDomainAction', Domain, arg, } if (arg === undefined) { cacheForNullary = result } return result }) as unknown as RemeshDomain Domain.type = 'RemeshDomain' Domain.domainId = domainUid++ Domain.domainName = options.name Domain.impl = options.impl as (context: RemeshDomainContext, arg: U[0]) => T Domain.inspectable = options.inspectable ?? true return Domain } export const DefaultDomain: RemeshDomain<{}, []> = RemeshDomain({ name: 'DefaultDomain', impl: () => { return {} }, })