// // Copyright 2025 DXOS.org // // @import-as-namespace import * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; import * as Effectable from 'effect/Effectable'; import * as Layer from 'effect/Layer'; import * as Option from 'effect/Option'; import * as Schema from 'effect/Schema'; import { EffectEx } from '@dxos/effect'; import { invariant } from '@dxos/invariant'; import { type SpaceId, type URI } from '@dxos/keys'; import type * as Entity from './Entity'; import * as Err from './Err'; import type * as Filter from './Filter'; import type * as Hypergraph from './Hypergraph'; import { type AnyProperties, EntityKind, KindId } from './internal/common/types'; // Deep import (not the `./internal/Entity` barrel) to avoid a cycle: // Database → internal/Entity → entity → JsonSchema → Ref → Database. import { isInstanceOf } from './internal/Entity/type-uri'; import type { Ref } from './internal/Ref/ref'; import type * as Obj from './Obj'; import type * as Query from './Query'; import type * as QueryResult from './QueryResult'; import type * as Registry from './Registry'; import type * as Type from './Type'; /** * `query` API function declaration. */ // TODO(burdon): Reconcile Query and Filter (should only have one root type). export interface QueryFn { (query: Q): QueryResult.QueryResult>; (filter: F): QueryResult.QueryResult>; } /** * Common interface for Database, Feed, and Hypergraph. */ export interface Queryable { query: QueryFn; } export type GetObjectByIdOptions = { deleted?: boolean; }; export type ObjectPlacement = 'root-doc' | 'linked-doc'; export type AddOptions = { /** * Where to place the object in the Automerge document tree. * Root document is always loaded with the space. * Linked documents are loaded lazily. * Placing large number of objects in the root document may slow down the initial load. * * @default 'linked-doc' */ placeIn?: ObjectPlacement; }; /** * Rejects Type entities from {@link Database.add} at compile time via their `[KindId]` brand. Used * as `T & RejectTypeEntity` to preserve inference of `T`. Bounding `add` on * `Obj.Unknown | Relation.Unknown` instead would reject broadly-typed instance adds (e.g. * `Entity.Any`, `Obj.OfShape`), forcing casts repo-wide. */ export type RejectTypeEntity = T extends { readonly [KindId]: EntityKind.Type } ? { __error: 'Type entities must be persisted via db.addType(), not db.add().' } : T; export type FlushOptions = { /** * Write any pending changes to disk. * @default true */ disk?: boolean; /** * Wait for pending index updates. * @default true */ indexes?: boolean; /** * Flush pending updates to objects and queries. * @default false */ updates?: boolean; }; /** * Identifier denoting an ECHO Database. */ export const TypeId = Symbol.for('@dxos/echo/Database'); export type TypeId = typeof TypeId; /** * ECHO Database interface. */ export interface Database extends Queryable { readonly [TypeId]: TypeId; get spaceId(): SpaceId; get graph(): Hypergraph.Hypergraph; /** * Registry for this database. Delegates type lookups to the shared hypergraph registry. * To persist a schema so it replicates to other clients, add the type entity with * {@link addType} (e.g. `await db.addType(Type.makeObjectFromJsonSchema(...))`). */ readonly registry: Registry.Registry; toJSON(): object; /** * Return object by local ID. * @deprecated Use `db.query(Filter.id(id)).runSync()[0]` for a working-set lookup, or resolve via a {@link Ref}. */ getObjectById>( id: string, opts?: GetObjectByIdOptions, ): T | undefined; /** * Query objects. */ query: QueryFn; /** * Creates a reference to an existing object in the database. * * NOTE: The reference may be dangling if the object is not present in the database. * NOTE: Difference from `Ref.fromURI` * `Ref.fromURI(dxn)` returns an unhydrated reference. The `.load` and `.target` APIs will not work. * `db.makeRef(dxn)` is preferable in cases with access to the database. */ makeRef(uri: URI.URI): Ref; /** * Adds an object or relation to the database. * * Only Object and Relation entities are accepted. To persist a Type definition use * {@link addType} — passing a Type entity is rejected at compile time (and at runtime). */ add(obj: T & RejectTypeEntity, opts?: AddOptions): T; /** * Persists a Type definition (clones/forks the entity) so it replicates to other peers. * * Runs a conflict query first: if a type with the same typename + version already exists in * this space, the existing persisted entity is returned and no duplicate is created. This is * the only supported way to add Type entities — {@link add} rejects them. */ addType(type: T): Promise; /** * Removes object from the database. */ // TODO(burdon): Return true if removed (currently throws if not present). remove(obj: Entity.Unknown): void; /** * Wait for all pending changes to be saved to disk. * Optionaly waits for changes to be propagated to indexes and event handlers. */ flush(opts?: FlushOptions): Promise; } export const isDatabase = (obj: unknown): obj is Database => { return obj ? typeof obj === 'object' && TypeId in obj && obj[TypeId] === TypeId : false; }; export const Database: Schema.Schema = Schema.Any.pipe(Schema.filter((space) => isDatabase(space))); /** * Effect service tag for Database dependency injection. */ export class Service extends Context.Tag('@dxos/echo/Database/Service')< Service, { readonly db: Database; } >() {} /** * Layer that provides a Database service that throws when accessed. * Useful as a default layer when no database is available. */ export const notAvailable = Layer.succeed(Service, { get db(): Database { throw new Error('Database not available'); }, }); /** * Creates a Database service instance from a Database. */ export const makeService = (db: Database): Context.Tag.Service => { return { get db() { return db; }, }; }; /** * Creates a Layer that provides the Database service. */ export const layer = (db: Database): Layer.Layer => { return Layer.succeed(Service, makeService(db)); }; /** * Returns the space ID of the database. */ export const spaceId = Effect.gen(function* () { const { db } = yield* Service; return db.spaceId; }); /** * Resolves an object by its DXN. */ export const resolve: { // No type check. (ref: URI.URI | Ref): Effect.Effect; // Check matches schema. ( ref: URI.URI | Ref, schema: S, ): Effect.Effect, Err.EntityNotFoundError, Service>; } = (( ref: URI.URI | Ref, schema?: S, ): Effect.Effect, Err.EntityNotFoundError, Service> => Effect.gen(function* () { const { db } = yield* Service; const dxn = typeof ref === 'string' ? ref : ref.uri; const object = yield* EffectEx.promiseWithCauseCapture(() => db.graph .createRefResolver({ context: { space: db.spaceId, }, }) .resolve(dxn), ); if (!object) { return yield* Effect.fail(new Err.EntityNotFoundError(dxn)); } // `isInstanceOf` uses a conditional generic that TS can't resolve through // the local `S extends Type.AnyEntity` parameter — runtime accepts it fine. invariant(!schema || isInstanceOf(schema as any, object), 'Object type mismatch.'); return object as any; }).pipe(Effect.withSpan('Database.resolve'))) as any; /** * Loads an object reference. * * Catching not found error: * * ```ts * yield* load(ref).pipe(Effect.catchTag('EntityNotFoundError', () => Effect.succeed(undefined))); * ``` * */ export const load: (ref: Ref) => Effect.Effect = Effect.fn('Database.load')( function* (ref) { const object = yield* EffectEx.promiseWithCauseCapture(() => ref.tryLoad()); if (!object) { return yield* Effect.fail(new Err.EntityNotFoundError(ref.uri)); } return object; }, ); /** * Adds an object or relation to the database. * @see {@link Database.add} */ export const add = (obj: T & RejectTypeEntity): Effect.Effect => Service.pipe(Effect.map(({ db }) => db.add(obj))).pipe(Effect.withSpan('Database.add')); /** * Persists a Type definition to the database. * @see {@link Database.addType} */ export const addType = (type: T): Effect.Effect => Service.pipe(Effect.flatMap(({ db }) => EffectEx.promiseWithCauseCapture(() => db.addType(type)))).pipe( Effect.withSpan('Database.addType'), ); /** * Removes an object from the database. * @see {@link Database.remove} */ export const remove = (obj: T): Effect.Effect => Service.pipe(Effect.map(({ db }) => db.remove(obj))).pipe(Effect.withSpan('Database.remove')); /** * Flushes pending changes to disk. * @see {@link Database.flush} */ export const flush = (opts?: FlushOptions) => Service.pipe(Effect.flatMap(({ db }) => EffectEx.promiseWithCauseCapture(() => db.flush(opts)))).pipe( Effect.withSpan('Database.flush'), ); /** * Creates a `QueryResult` object that can be subscribed to. */ export const query: { (query: Q): QueryResult.QueryResultEffect, never, Service>; (filter: F): QueryResult.QueryResultEffect, never, Service>; } = (queryOrFilter: Query.Any | Filter.Any) => Service.pipe( Effect.map(({ db }) => db.query(queryOrFilter as any) as QueryResult.QueryResult), Effect.withSpan('Database.query'), makeQueryResultEffect, ); const makeQueryResultEffect = ( eff: Effect.Effect, never, Service>, ): QueryResult.QueryResultEffect => { return { run: Effect.flatMap(eff, (result) => EffectEx.promiseWithCauseCapture(() => result.run())), first: Effect.flatMap(eff, (result) => EffectEx.promiseWithCauseCapture(async () => Option.fromNullable(await result.firstOrUndefined())), ), // Effect internals ...Effectable.CommitPrototype, commit() { return eff; }, } as any; };