// // Copyright 2025 DXOS.org // // @import-as-namespace import type * as EffectArray from 'effect/Array'; import type * as Schema from 'effect/Schema'; import { type QueryAST } from '@dxos/echo-protocol'; import { type URI } from '@dxos/keys'; import type * as Collection from './Collection'; import * as Database from './Database'; import type * as Dataset from './Dataset'; import * as Feed from './Feed'; import * as Filter from './Filter'; import * as internal from './internal'; import * as Obj from './Obj'; import type * as Order from './Order'; import type * as Ref from './Ref'; import type * as Relation from './Relation'; // eslint-disable-next-line @dxos/rules/import-as-namespace import type * as Type$ from './Type'; import type * as View from './View'; // TODO(dmaretskyi): Split up into interfaces for objects and relations so they can have separate verbs. // TODO(dmaretskyi): Undirected relation traversals. // TODO(wittjosiah): Make Filter & Query pipeable. /** * All property paths inside T that are references. */ // TODO(dmaretskyi): Filter only properties that are references (or optional references, or unions that include references). type RefPropKey = keyof T & string; type RefArrayElement = A extends readonly (infer E)[] ? E : A extends (infer E)[] ? E : never; /** Target entity when traversing an outgoing ref or array-of-refs property. */ type ReferenceTraversalTarget

= P extends Ref.Unknown ? Ref.Target

: P extends Ref.Unknown | undefined ? Ref.Target> : RefArrayElement

extends Ref.Unknown ? Ref.Target> : never; // TODO(burdon): Narrow T to Entity.Unknown? export interface Query { // TODO(dmaretskyi): See new effect-schema approach to variance. '~Query': { value: T }; ast: QueryAST.Query; /** * Filter the current selection based on a filter. * @param filter - Filter to select the objects. * @returns Query for the selected objects. */ 'select'(filter: Filter.Filter): Query; 'select'(props: Filter.Props): Query; /** * Traverse an outgoing reference. * @param key - Property path inside T that is a reference or optional reference. * @returns Query for the target of the reference. */ 'reference'>(key: K): Query>; /** * Find objects referencing this object. * @param target - Schema of the referencing object. If not provided, matches any type. * @param key - Property path inside the referencing object that is a reference. If not provided, matches any property. * @returns Query for the referencing objects. */ // TODO(dmaretskyi): any way to enforce `Ref.Target[key]> == T`? // TODO(dmaretskyi): Ability to go through arrays of references. 'referencedBy'( target: S | URI.URI, key: RefPropKey>, ): Query>; 'referencedBy'(target: S | URI.URI): Query>; 'referencedBy'(): Query; /** * Find relations where this object is the source. * @returns Query for the relation objects. * @param relation - Schema of the relation. * @param predicates - Predicates to filter the relation objects. */ 'sourceOf'( relation?: R | URI.URI, predicates?: Filter.Props>, ): Query>; /** * Find relations where this object is the target. * @returns Query for the relation objects. * @param relation - Type entity of the relation. * @param predicates - Predicates to filter the relation objects. */ 'targetOf'( relation?: R | URI.URI, predicates?: Filter.Props>, ): Query>; /** * For a query for relations, get the source objects. * @returns Query for the source objects. */ 'source'(): Query>; /** * For a query for relations, get the target objects. * @returns Query for the target objects. */ 'target'(): Query>; /** * Get the parent object of the current selection. * @returns Query for the parent objects. */ 'parent'(): Query; /** * Get all child objects of the current selection. * @returns Query for the child objects. */ 'children'(): Query; /** * Order the query results. * Orders are specified in priority order. The first order will be applied first, etc. * @param order - Order to sort the results. * @returns Query for the ordered results. */ 'orderBy'(...order: EffectArray.NonEmptyArray>): Query; /** * Limit the number of results. * @param limit - Maximum number of results to return. * @returns Query for the limited results. */ 'limit'(limit: number): Query; /** * Query from selected databases only. * * Example: * * ```ts * Query.select(Filter.type(Person)).from(db); * ``` * * @param options.includeFeeds [false] - Whether to include feeds in the query. Default is to query from automerge documents only. */ 'from'(database: Database.Database | Database.Database[], options?: { includeFeeds?: boolean }): Query; /** * Query from selected feeds only. * * Example: * * ```ts * Query.select(Filter.type(Person)).from(feed); * ``` * */ 'from'(feeds: Feed.Feed | Feed.Feed[]): Query; /** * Query from all accessible spaces. * * Example: * * ```ts * Query.select(Filter.type(Person)).from('all-accessible-spaces'); * ``` * * @param options.includeFeeds [false] - Whether to include feeds in the query. Default is to query from automerge documents only. */ 'from'(allSpaces: 'all-accessible-spaces', options?: { includeFeeds?: boolean }): Query; /** * Query from a dataset. * Currently only feeds are supported. * * Example: * * ```ts * Query.type(Person).from(feed); * ``` */ 'from'(dataset: Dataset.Dataset): Query; /** * Query from the results of another query. * * Example: * * ```ts * Query.select(Filter.props({ foo: 'foo' })).from(Query.select(Filter.type(Contact)).reference('org')); * ``` */ 'from'(query: Any): Query; /** * Query from one or more raw scopes. * * Use the {@link Scope} constructors rather than raw tagged objects: * * ```ts * Query.select(Filter.type(Type.Type)).from(Scope.space(), Scope.registry()); * ``` */ 'from'(...scopes: QueryAST.Scope[]): Query; /** * Query from a raw scope or array of scopes. */ 'from'(scope: QueryAST.Scope | QueryAST.Scope[]): Query; /** * Add options to a query. */ 'options'(options: QueryAST.QueryOptions): Query; /** * Attach a diagnostic label for logs and tooling (execution semantics unchanged). */ 'debugLabel'(label: string): Query; } export type Any = Query; export type Type = Q extends Query ? T : never; class QueryClass implements Any { private static 'variance': Any['~Query'] = {} as Any['~Query']; constructor(public readonly ast: QueryAST.Query) {} '~Query' = QueryClass.variance; select(filter: Filter.Any | Filter.Props): Any { if (Filter.is(filter)) { return new QueryClass({ type: 'filter', selection: this.ast, filter: filter.ast, }); } else { return new QueryClass({ type: 'filter', selection: this.ast, filter: Filter.props(filter).ast, }); } } reference(key: string): Any { return new QueryClass({ type: 'reference-traversal', anchor: this.ast, property: key, }); } referencedBy(target?: Type$.AnyEntity | URI.URI, key?: string): Any { const uri = target !== undefined ? internal.getTypeURIFromSpecifier(target) : null; return new QueryClass({ type: 'incoming-references', anchor: this.ast, property: key ?? null, typename: uri ?? null, }); } sourceOf(relation?: Type$.AnyRelation | URI.URI, predicates?: Filter.Props | undefined): Any { return new QueryClass({ type: 'relation', anchor: this.ast, direction: 'outgoing', filter: relation !== undefined ? Filter.type(relation, predicates).ast : undefined, }); } targetOf(relation?: Type$.AnyRelation | URI.URI, predicates?: Filter.Props | undefined): Any { return new QueryClass({ type: 'relation', anchor: this.ast, direction: 'incoming', filter: relation !== undefined ? Filter.type(relation, predicates).ast : undefined, }); } source(): Any { return new QueryClass({ type: 'relation-traversal', anchor: this.ast, direction: 'source', }); } target(): Any { return new QueryClass({ type: 'relation-traversal', anchor: this.ast, direction: 'target', }); } parent(): Any { return new QueryClass({ type: 'hierarchy-traversal', anchor: this.ast, direction: 'to-parent', }); } children(): Any { return new QueryClass({ type: 'hierarchy-traversal', anchor: this.ast, direction: 'to-children', }); } orderBy(...order: Order.Order[]): Any { return new QueryClass({ type: 'order', query: this.ast, order: order.map((o) => o.ast), }); } limit(limit: number): Any { return new QueryClass({ type: 'limit', query: this.ast, limit, }); } from( ...args: | [ ( | Database.Database | Database.Database[] | Feed.Feed | Feed.Feed[] | Collection.Collection | View.View | Any | QueryAST.Scope | QueryAST.Scope[] | 'all-accessible-spaces' ), { includeFeeds?: boolean }?, ] | QueryAST.Scope[] ): Any { // Variadic raw scopes: `.from(Scope.space(), Scope.registry())`. if (args.length > 1 && args.every(_isRawScope)) { return new QueryClass({ type: 'from', query: this.ast, from: { _tag: 'scope', scopes: args as QueryAST.Scope[] }, }); } const [arg, options] = args as [ ( | Database.Database | Database.Database[] | Feed.Feed | Feed.Feed[] | Collection.Collection | View.View | Any | QueryAST.Scope | QueryAST.Scope[] | 'all-accessible-spaces' ), { includeFeeds?: boolean }?, ]; if (arg == null) { throw new TypeError( 'Query.from() requires a valid data source argument (database, feed, query, scope, or "all-accessible-spaces").', ); } if (is(arg)) { return new QueryClass({ type: 'from', query: this.ast, from: { _tag: 'query', query: arg.ast }, }); } if (arg === 'all-accessible-spaces') { return new QueryClass({ type: 'from', query: this.ast, from: { _tag: 'scope', scopes: [] }, }); } // Raw scope(s): tagged union objects with _tag 'space' | 'feed' | 'registry'. if (Array.isArray(arg) && arg.every(_isRawScope)) { return new QueryClass({ type: 'from', query: this.ast, from: { _tag: 'scope', scopes: arg as QueryAST.Scope[] }, }); } if (_isRawScope(arg)) { return new QueryClass({ type: 'from', query: this.ast, from: { _tag: 'scope', scopes: [arg] }, }); } const items = Array.isArray(arg) ? arg : [arg]; if (items.length > 0 && Database.isDatabase(items[0])) { const databases = items as Database.Database[]; return new QueryClass({ type: 'from', query: this.ast, from: { _tag: 'scope', scopes: databases.map((db) => ({ _tag: 'space' as const, spaceId: db.spaceId, ...(options?.includeFeeds ? { includeAllFeeds: true } : {}), })), }, }); } if (items.length > 0) { const typename = Obj.getTypename(items[0] as Obj.Unknown); // TODO(dmaretskyi): Support querying from views. if (typename === 'org.dxos.type.view') { throw new Error('Query.from(view) is not yet supported.'); } // TODO(dmaretskyi): Support querying from collections. if (typename === 'org.dxos.type.collection') { throw new Error('Query.from(collection) is not yet supported.'); } // Validate that the items are Feed.Feed instances. for (const item of items) { if (!Obj.instanceOf(Feed.Feed, item)) { throw new TypeError( `Query.from() expects Feed objects (org.dxos.type.feed), but received an object with typename '${typename ?? 'unknown'}'.`, ); } } } const feedItems = items as Feed.Feed[]; const feedScopes = feedItems.map((feed) => { const uri = Feed.getQueueUri(feed); if (!uri) { throw new TypeError( `Query.from() expects persisted Feed objects with a queue URI; got feed without a space (id=${Obj.getURI(feed)}).`, ); } return { _tag: 'feed' as const, feedUri: String(uri) }; }); return new QueryClass({ type: 'from', query: this.ast, from: { _tag: 'scope', scopes: feedScopes }, }); } options(options: QueryAST.QueryOptions): Any { return new QueryClass({ type: 'options', query: this.ast, options, }); } debugLabel(label: string): Any { if (this.ast.type === 'options') { return new QueryClass({ type: 'options', query: this.ast.query, options: { ...this.ast.options, debugLabel: label }, }); } return new QueryClass({ type: 'options', query: this.ast, options: { debugLabel: label }, }); } } export const is = (value: unknown): value is Any => { return typeof value === 'object' && value !== null && '~Query' in value; }; /** Construct a query from an ast. */ export const fromAst = (ast: QueryAST.Query): Any => { return new QueryClass(ast); }; /** * Select objects based on a filter. * @param filter - Filter to select the objects. * @returns Query for the selected objects. */ export const select = (filter: F): Query> => { return new QueryClass({ type: 'select', filter: filter.ast, }); }; /** * Query for objects of a given schema. * @param schema - Schema of the objects. * @param predicates - Predicates to filter the objects. * @returns Query for the objects. * * Shorthand for: `Query.select(Filter.type(schema, predicates))`. */ export const type: { (type: T, predicates?: Filter.Props>): Query>; // Brand-narrowed schema overload — only well-known unknown schemas pass. >( schema: S, predicates?: Filter.Props>, ): Query>; >( union: S, predicates?: Filter.Props>, ): Query>; (uri: URI.URI, predicates?: Filter.Props): Query; } = (type: Type$.AnyEntity | URI.URI, predicates?: Filter.Props): Any => { return new QueryClass({ type: 'select', filter: Filter.type(type, predicates).ast, }); }; /** * Combine results of multiple queries. * @param queries - Queries to combine. * @returns Query for the combined results. */ // TODO(dmaretskyi): Rename to `combine` or `union`. export const all = (...queries: Any[]): Any => { if (queries.length === 0) { throw new TypeError( 'Query.all combines results of multiple queries, to query all objects use Query.select(Filter.everything())', ); } return new QueryClass({ type: 'union', queries: queries.map((q) => q.ast), }); }; /** * Subtract one query from another. * @param source - Query to subtract from. * @param exclude - Query to subtract. * @returns Query for the results of the source query minus the results of the exclude query. */ export const without = (source: Query, exclude: Query): Query => { return new QueryClass({ type: 'set-difference', source: source.ast, exclude: exclude.ast, }); }; /** * Create a query scoped to a data source. * The returned query selects everything from the source; chain `.select()` to narrow results. * * @param source - Data source: database, feed, 'all-accessible-spaces', or another query. * @returns Query scoped to the given source. */ export const from = ( ...args: | [ ( | Database.Database | Database.Database[] | Feed.Feed | Feed.Feed[] | Any | QueryAST.Scope | QueryAST.Scope[] | 'all-accessible-spaces' ), { includeFeeds?: boolean }?, ] | QueryAST.Scope[] ): Any => { const baseQuery: QueryAST.Query = { type: 'select', filter: Filter.everything().ast, }; const wrapper = new QueryClass(baseQuery); return (wrapper.from as (...args: unknown[]) => Any)(...args); }; const SCOPE_TAGS = new Set(['space', 'feed', 'registry']); /** Detect a raw Scope tagged-union object. */ const _isRawScope = (value: unknown): value is QueryAST.Scope => { return ( typeof value === 'object' && value !== null && !Array.isArray(value) && '_tag' in value && typeof value._tag === 'string' && SCOPE_TAGS.has(value._tag) ); }; /** * Returns a human-readable string representation of a Query AST. */ export const pretty = (query: Any): string => internal.prettyQuery(query.ast);