import type { ApplogForInsertOptionalAgent, ApplogValue, EntityID } from '../applog/datom-types.ts' import { EntityID_LENGTH } from '../applog/datom-types.ts' import { dateNowIso, getHashID } from '../applog/applog-utils.ts' import { ensureTsPvAndFinalizeApplog } from '../applog/applog-helpers.ts' import { lastWriteWins, liveEntityAt } from '../query/basic.ts' import { SubscribableImpl } from '../query/subscribable.ts' import type { Thread } from '../thread/basic.ts' import { isInitEvent } from '../thread/basic.ts' import { rollingFilter } from '../thread/filters.ts' import { ObjectBuilder } from './builder.ts' import { type ISignalAdapter, type IVMInstance, type ViewModelFactoryOptions, type VMInstanceMap, getInstancesForThread, } from './types.ts' // ═══════════════════════════════════════════════════════════════ // Default (non-reactive) Signal Adapter // ═══════════════════════════════════════════════════════════════ /** * Default signal adapter — reads Subscribable values directly (snapshot). * Subscribe-once to keep .value current. No framework reactivity. */ export const DefaultSignalAdapter: ISignalAdapter = { createGetter(subscribable: { value: T; subscribe(cb: () => void): () => void }): () => T { // Subscribe once to activate upstream (lazy activation) subscribable.subscribe(() => {}) return () => subscribable.value }, createWritable(initial: T): [() => T, (v: T) => void] { let current = initial return [() => current, (v: T) => { current = v }] }, } // ═══════════════════════════════════════════════════════════════ // makeCurrentThread - convenience wrapper // ═══════════════════════════════════════════════════════════════ function makeCurrent(thread: Thread, fn?: ViewModelFactoryOptions['makeCurrentThread']): Thread { if (fn) return fn(thread) return lastWriteWins(thread, { tolerateAlreadyFiltered: true }) } // ═══════════════════════════════════════════════════════════════ // Default persist function // ═══════════════════════════════════════════════════════════════ /** * Default applog persistence: finalize applogs and push into thread. * Works with WriteableThread (which has insertRaw). */ function defaultPersistApplogs(thread: Thread, applogs: ApplogForInsertOptionalAgent[]): void { const finalized = applogs.map(log => ensureTsPvAndFinalizeApplog(log as any, thread)) const writable = thread as any if (typeof writable.insertRaw === 'function') { writable.insertRaw(finalized) } else if (typeof writable.insert === 'function') { writable.insert(applogs) } else { // Read-only thread — just finalize but don't persist // This is fine for optimistic UI or read-only scenarios } } // ═══════════════════════════════════════════════════════════════ // createViewModelFactory - THE MAIN FACTORY // ═══════════════════════════════════════════════════════════════ /** * Create a ViewModel factory for a given schema. * * Returns a base class that can be extended or used directly. * The class provides: * - Static `get(en, thread)` for singleton access * - Static `buildNew(init, en)` for creating entities * - Instance `buildUpdate(init)` for updating entities * - Lazy reactive property accessors per attribute * - `setDeleted()` for soft deletion * * Framework-specific reactivity (Solid, Vue, etc.) can be added by * providing a custom `ISignalAdapter` in the options. * * @param options - Factory configuration * @returns A VM class constructor with static methods */ export function createViewModelFactory>( options: ViewModelFactoryOptions, ) { const { adapter, entityPrefix = adapter.getEntityPrefix(), entityIdLength = EntityID_LENGTH, generateEntityId, vmName = entityPrefix, signalAdapter = DefaultSignalAdapter, } = options const attrDefs = adapter.getAttributeDefs() const defaults = adapter.getDefaults() // Entity ID generation const genId = generateEntityId ?? ((data: Partial & { ts?: string }) => getHashID({ ...data, ts: data.ts ?? dateNowIso() }, entityIdLength) as EntityID) // Persist function (can be overridden by extending class) const persistFn = (thread: Thread, applogs: ApplogForInsertOptionalAgent[]) => { defaultPersistApplogs(thread, applogs) } // ═══════════════════════════════════════════════════════════════ // The ViewModel Class // ═══════════════════════════════════════════════════════════════ class ViewModel { /** Per-instance signal storage */ _signals = new Map ApplogValue>() _signalAdapter: ISignalAdapter = signalAdapter /** Reactive map for applog tracking: vl === this.en */ _targetMap: SubscribableImpl>> /** Applogs in the current thread where vl === this.en */ _targetApplogs: Thread constructor( public en: EntityID, public thread: Thread, skipInit?: boolean, ) { if (skipInit) return const currentThread = makeCurrent(thread, options.makeCurrentThread) // Define lazy getters/setters for each attribute for (const attr of attrDefs) { if (attr.name === 'en') continue const attrName = attr.name const atPath = attr.atPath const defaultValue = attr.defaultValue ?? defaults?.[attrName as keyof T] Object.defineProperty(this, attrName, { get(this: ViewModel) { let signal = this._signals.get(attrName) if (!signal) { const subscribable = liveEntityAt(currentThread, this.en, atPath) signal = this._signalAdapter.createGetter(subscribable) this._signals.set(attrName, signal) } const value = signal() return value ?? (defaultValue as ApplogValue | undefined) ?? value }, set(this: ViewModel, v: ApplogValue) { const applog: ApplogForInsertOptionalAgent = { en: this.en, at: atPath, vl: v } persistFn(this.thread, [applog]) }, enumerable: true, configurable: true, }) } // ── Applog tracking: applogs where vl === this.en ──────────── const targets = new Map>() this._targetApplogs = rollingFilter(currentThread, { vl: this.en }) this._targetMap = new SubscribableImpl( targets, () => this._targetApplogs.subscribe((event) => { if (isInitEvent(event)) { targets.clear() for (const log of event.init) { let ats = targets.get(log.en) if (!ats) { ats = new Set() targets.set(log.en, ats) } ats.add(log.at) } } else { for (const log of event.added) { let ats = targets.get(log.en) if (!ats) { ats = new Set() targets.set(log.en, ats) } ats.add(log.at) } if (event.removed) { for (const log of event.removed) { const ats = targets.get(log.en) if (ats) { ats.delete(log.at) if (ats.size === 0) targets.delete(log.en) } } } } this._targetMap._set(targets) }, 'derived'), ) } /** Thread scoped to this entity */ get entityThread(): Thread { return rollingFilter(this.thread, { en: this.en }) } /** Applogs in the current thread where vl === this.en */ get targetApplogs(): Thread { return this._targetApplogs } /** Set of entities that have an applog with this.en as the vl */ get targettedBy(): Set { return new Set(this._targetMap.value.keys()) } /** Set of at strings from applogs where vl === this.en */ get targettedVia(): Set { const via = new Set() for (const ats of this._targetMap.value.values()) { for (const at of ats) via.add(at) } return via } /** Whether this entity is soft-deleted */ get isDeleted(): boolean { let signal = this._signals.get('__isDeleted') if (!signal) { const currentThread = makeCurrent(this.thread, options.makeCurrentThread) const subscribable = liveEntityAt(currentThread, this.en, 'isDeleted') signal = this._signalAdapter.createGetter(subscribable) this._signals.set('__isDeleted', signal) } return !!signal() } /** Soft-delete this entity */ setDeleted(thread = this.thread): void { const applog: ApplogForInsertOptionalAgent = { en: this.en, at: 'isDeleted', vl: true } persistFn(thread, [applog]) } /** Get a builder for updating this entity */ buildUpdate(init: Partial = {}): ObjectBuilder { return new ObjectBuilder(init, this.en, entityPrefix) } /** Description for debugging */ get description(): string { return `${vmName}VM(en=${this.en})` } /** Get the full entity state as a plain object */ toJSON(): Partial { const result: Record = {} for (const attr of attrDefs) { const value = (this as any)[attr.name] if (value !== undefined && value !== null) { result[attr.name] = value } } return result as Partial } } // ── Static Methods ──────────────────────────────────────────── /** * Get or create a VM instance for the given entity and thread. * Implements the singleton pattern — returns cached instance if one exists. */ function getVMClass(en: EntityID, thread: Thread): InstanceType { if (!en || typeof en !== 'string') throw new Error(`[${vmName}VM.get] invalid en: ${en}`) if (!thread) throw new Error(`[${vmName}VM.get] no thread provided`) const threadMap = getInstancesForThread(thread) let entityMap = threadMap.get(vmName) as VMInstanceMap> | undefined if (!entityMap) { entityMap = new Map() threadMap.set(vmName, entityMap) } const existing = entityMap.get(en) if (existing) return existing const vm = new ViewModel(en, thread, false) as InstanceType entityMap.set(en, vm) return vm } /** * Create a builder for constructing a new entity. */ function buildNewEntity(init: Partial = {}, en?: EntityID): ObjectBuilder { const entityId = en ?? genId(init as any) return ObjectBuilder.create(init, entityId, entityPrefix) } // Attach static methods to the class const VMClass: any = ViewModel VMClass.get = getVMClass VMClass.buildNew = buildNewEntity VMClass.vmName = vmName VMClass.entityPrefix = entityPrefix return VMClass as unknown as { new( en: EntityID, thread: Thread, skipInit?: boolean, ): IVMInstance & T /** Get or create a VM instance for the given entity ID */ get(en: EntityID, thread: Thread): IVMInstance & T /** Create a builder for a new entity */ buildNew(init?: Partial, en?: EntityID): ObjectBuilder /** The VM name (for debugging) */ readonly vmName: string /** The entity prefix used for at-paths */ readonly entityPrefix: string } }