// // Copyright 2025 DXOS.org // import * as Atom from '@effect-atom/atom/Atom'; import * as Result from '@effect-atom/atom/Result'; import * as Effect from 'effect/Effect'; import * as Function from 'effect/Function'; import * as Option from 'effect/Option'; import { assertArgument } from '@dxos/invariant'; import type * as Entity from '../../Entity'; import type * as Obj from '../../Obj'; import type * as Ref from '../../Ref'; import type * as Relation from '../../Relation'; import { subscribe } from '../common/proxy/reactive'; import { isEntity, getDatabase } from '../Entity'; import { RefTypeId } from '../Ref/ref'; import { loadRefTarget } from '../Ref/utils'; import { getSnapshot } from './snapshot'; const isRef = (obj: unknown): obj is Ref.Ref => obj != null && typeof obj === 'object' && RefTypeId in (obj as object); const getReactiveOption = (snapshot: Obj.Snapshot): Effect.Effect, never> => Effect.gen(function* () { const db = getDatabase(snapshot as any); if (!db) { return Option.none(); } const obj = db.getObjectById((snapshot as any).id); return obj ? Option.some(obj as T) : Option.none(); }); /** * Atom family for ECHO objects. * Uses object reference as key — same object returns same atom. */ const objectFamily = Atom.family((obj: T): Atom.Atom> => { return Atom.make>((get) => { const unsubscribe = subscribe(obj, () => { // getSnapshot adds SnapshotKindId brand at runtime; cast bridges static types. get.setSelf(getSnapshot(obj) as unknown as Obj.Snapshot); }); get.addFinalizer(() => unsubscribe()); return getSnapshot(obj) as unknown as Obj.Snapshot; }).pipe(Atom.keepAlive); }); /** * Atom family for ECHO refs (snapshot version). * Uses ref as key — same ref returns same atom. * Subscribes to target object changes after loading. */ const refFamily = Atom.family((ref: Ref.Ref): Atom.Atom | undefined> => { return Atom.make | undefined>((get) => { let unsubscribeTarget: (() => void) | undefined; const setupTargetSubscription = (target: T): Obj.Snapshot => { unsubscribeTarget?.(); unsubscribeTarget = subscribe(target, () => { // getSnapshot adds SnapshotKindId brand at runtime; cast bridges static types. get.setSelf(getSnapshot(target) as unknown as Obj.Snapshot); }); return getSnapshot(target) as unknown as Obj.Snapshot; }; get.addFinalizer(() => { unsubscribeTarget?.(); }); return loadRefTarget(ref, get, setupTargetSubscription); }).pipe(Atom.keepAlive); }); /** * Snapshot a value to create a new reference for comparison and React dependency tracking. */ const snapshotForComparison = (value: V): V => { if (Array.isArray(value)) { return [...value] as V; } if (value !== null && typeof value === 'object') { return { ...value } as V; } return value; }; /** * Atom family for ECHO object properties. * Uses nested families: outer keyed by object, inner keyed by property key. */ const propertyFamily = Atom.family((obj: T) => Atom.family((key: K): Atom.Atom => { return Atom.make((get) => { let previousSnapshot = snapshotForComparison(obj[key]); const unsubscribe2 = subscribe(obj, () => { const newValue = obj[key]; const newSnapshot = snapshotForComparison(newValue); if (newSnapshot !== previousSnapshot) { previousSnapshot = newSnapshot; get.setSelf(newSnapshot); } }); get.addFinalizer(() => unsubscribe2()); return snapshotForComparison(obj[key]); }).pipe(Atom.keepAlive); }), ); /** * Atom family for ECHO objects — returns the live object, not a snapshot. */ const objectWithReactiveFamily = Atom.family((obj: T): Atom.Atom => { return Atom.make((get) => { const unsubscribe = subscribe(obj, () => { get.setSelf(obj); }); get.addFinalizer(() => unsubscribe()); return obj; }).pipe(Atom.keepAlive); }); /** * Atom family for ECHO refs — returns the live reactive object, not a snapshot. */ const refWithReactiveFamily = Atom.family((ref: Ref.Ref): Atom.Atom => { const effect = (get: Atom.Context) => Effect.gen(function* () { const snapshot = get(makeAtom(ref)); if (snapshot == null) { return undefined; } const option = yield* getReactiveOption(snapshot); return Option.getOrElse(option, () => undefined); }); return Function.pipe( Atom.make(effect), Atom.map((result) => Result.getOrElse(result, () => undefined)), ); }); /** * Atom family for any ECHO entity (obj or relation) — returns a snapshot. */ const entityFamily = Atom.family((entity: T): Atom.Atom => { return Atom.make((get) => { const unsubscribe = subscribe(entity, () => { // getSnapshot adds SnapshotKindId brand at runtime; cast bridges static types. get.setSelf(getSnapshot(entity) as unknown as Entity.Snapshot); }); get.addFinalizer(() => unsubscribe()); return getSnapshot(entity) as unknown as Entity.Snapshot; }).pipe(Atom.keepAlive); }); /** * Atom family for ECHO relations — returns a typed relation snapshot. */ const relationFamily = Atom.family((relation: T): Atom.Atom> => { return Atom.make>((get) => { const unsubscribe = subscribe(relation, () => { // getSnapshot adds SnapshotKindId brand at runtime; cast bridges static types. get.setSelf(getSnapshot(relation) as unknown as Relation.Snapshot); }); get.addFinalizer(() => unsubscribe()); return getSnapshot(relation) as unknown as Relation.Snapshot; }).pipe(Atom.keepAlive); }); /** * Create a read-only snapshot atom for a reactive object or ref. * Updates automatically when the object is mutated. * For refs, subscribes to target object changes after loading. */ export const makeAtom: { (obj: T): Atom.Atom>; (ref: Ref.Ref): Atom.Atom | undefined>; } = (objOrRef: Obj.Unknown | Ref.Ref): Atom.Atom => { if (isRef(objOrRef)) { return refFamily(objOrRef as any); } const obj = objOrRef as Obj.Unknown; assertArgument(isEntity(obj), 'obj', 'Object must be a reactive object'); return objectFamily(obj as any); }; /** * Create a read-only atom for a specific property of a reactive object. * Only fires updates when the property value actually changes. */ export const makeProperty = (obj: T, key: K): Atom.Atom => { assertArgument(isEntity(obj), 'obj', 'Object must be a reactive object'); return propertyFamily(obj)(key); }; /** * Like `makeAtom` but returns the live reactive object instead of a snapshot. * Prefer `makeAtom` (snapshot) unless you need the live Obj for generic mutations. */ export const makeWithReactive: { (obj: T): Atom.Atom; (ref: Ref.Ref): Atom.Atom; } = (objOrRef: Obj.Unknown | Ref.Ref): Atom.Atom => { if (isRef(objOrRef)) { return refWithReactiveFamily(objOrRef as Ref.Ref); } const obj = objOrRef as Obj.Unknown; assertArgument(isEntity(obj), 'obj', 'Object must be a reactive object'); return objectWithReactiveFamily(obj); }; /** * Create a read-only snapshot atom for any ECHO entity (obj or relation). * Updates automatically when the entity is mutated. */ export const makeEntity = (entity: T): Atom.Atom => { assertArgument(isEntity(entity), 'entity', 'Must be a reactive ECHO entity'); return entityFamily(entity); }; /** * Create a read-only snapshot atom for a reactive relation. * Updates automatically when the relation is mutated. */ export const makeRelation = (relation: T): Atom.Atom> => { assertArgument(isEntity(relation), 'relation', 'Must be a reactive ECHO relation'); return relationFamily(relation); };