import type { OrderedQuery as ConvexOrderedQuery, QueryInitializer as ConvexQueryInitializer, DocumentByInfo, GenericTableIndexes, Indexes, IndexRange, IndexRangeBuilder, NamedIndex, NamedSearchIndex, NamedTableInfo, Query, SearchFilter, SearchFilterBuilder, SearchIndexes, } from "convex/server"; import type { GenericId } from "convex/values"; import { pipe } from "effect/Function"; import * as Array from "effect/Array"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; import type { ReadonlyRecord } from "effect/Record"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { BaseDatabaseReader, IndexFieldTypesForEq, } from "@confect/core/Types"; import type * as DataModel from "./DataModel"; import * as Document from "./Document"; import * as OrderedQuery from "./OrderedQuery"; import * as QueryStream from "./QueryStream"; import type * as QueryStreamKeyLabels from "./QueryStreamKeyLabels"; import * as QueryStreamIndexRange from "./QueryStreamIndexRange"; import type { QueryStreamOrderDirection as OrderDirection } from "./QueryStreamOrderDirection"; import type * as Table from "./Table"; type ConvexTableInfoFor< DataModel_ extends DataModel.AnyWithProps, TableName extends DataModel.TableNames, > = DataModel.TableInfoWithName; type TableInfoFor< DataModel_ extends DataModel.AnyWithProps, TableName extends DataModel.TableNames, > = DataModel.TableInfoWithName_; export interface QueryInitializer< DataModel_ extends DataModel.AnyWithProps, TableName extends DataModel.TableNames, Doc = DataModel.DocumentWithName, > { readonly get: { ( id: GenericId, ): Effect.Effect; < IndexName extends keyof Indexes< ConvexTableInfoFor >, >( indexName: IndexName, ...indexFieldValues: IndexFieldTypesForEq< DataModel.ToConvex, TableName, Indexes>[IndexName] > ): Effect.Effect; }; readonly index: { < IndexName extends keyof Indexes< ConvexTableInfoFor >, >( indexName: IndexName, indexRange?: ( q: IndexRangeBuilder< TableInfoFor["convexDocument"], NamedIndex, IndexName> >, ) => IndexRange, order?: OrderDirection, ): OrderedQuery.OrderedQuery, Doc>; < IndexName extends keyof Indexes< ConvexTableInfoFor >, >( indexName: IndexName, order?: OrderDirection, ): OrderedQuery.OrderedQuery, Doc>; }; readonly search: < IndexName extends keyof SearchIndexes< ConvexTableInfoFor >, >( indexName: IndexName, searchFilter: ( q: SearchFilterBuilder< DocumentByInfo>, NamedSearchIndex, IndexName> >, ) => SearchFilter, ) => OrderedQuery.OrderedQuery, Doc>; /** * EXPERIMENTAL—stream-first querying (see `notes/stream-based-querying.md`). * * Like `index`, but returns a {@link QueryStream.QueryStream}: a genuine * Effect `Stream` of documents in index order that stays mergeable and * paginable. * * In SQL terms: an index range scan—`SELECT * FROM table WHERE ORDER * BY [DESC]`; `eq` calls are the equality predicates, the * bound calls are the range predicates. The value is a reusable description * of a query: each run re-runs the index query. * The typed range builder * consumes `eq`-pinned fields from the index's field tuple at the type level, * so the stream's visible label type is exactly the fields that still vary * (the `ORDER BY` columns left after the equality predicates). The order * direction is part of the type too: omitted, it is `"asc"`; a literal is * tracked as that literal, and a value known only at runtime as the union. * The order parameter is either absent or a direction—never `undefined`—so * the type can't claim a literal the runtime default would contradict. */ readonly stream: { < IndexName extends keyof Indexes< ConvexTableInfoFor > & string, Range extends QueryStreamIndexRange.QueryStreamIndexRange, >( indexName: IndexName, indexRange: ( q: QueryStreamIndexRange.Builder< TableInfoFor["convexDocument"], NamedIndex, IndexName> >, ) => Range, ): QueryStream.QueryStream< Doc, QueryStreamKeyLabels.QueryStreamKeyLabels< QueryStreamIndexRange.Remaining >, "asc", Document.DocumentDecodeError, never >; < IndexName extends keyof Indexes< ConvexTableInfoFor > & string, Range extends QueryStreamIndexRange.QueryStreamIndexRange, Direction extends OrderDirection, >( indexName: IndexName, indexRange: ( q: QueryStreamIndexRange.Builder< TableInfoFor["convexDocument"], NamedIndex, IndexName> >, ) => Range, order: Direction, ): QueryStream.QueryStream< Doc, QueryStreamKeyLabels.QueryStreamKeyLabels< QueryStreamIndexRange.Remaining >, Direction, Document.DocumentDecodeError, never >; < IndexName extends keyof Indexes< ConvexTableInfoFor > & string, >( indexName: IndexName, ): QueryStream.QueryStream< Doc, QueryStreamKeyLabels.QueryStreamKeyLabels< NamedIndex, IndexName> >, "asc", Document.DocumentDecodeError, never >; < IndexName extends keyof Indexes< ConvexTableInfoFor > & string, Direction extends OrderDirection, >( indexName: IndexName, order: Direction, ): QueryStream.QueryStream< Doc, QueryStreamKeyLabels.QueryStreamKeyLabels< NamedIndex, IndexName> >, Direction, Document.DocumentDecodeError, never >; }; } export const make = < Tables extends Table.AnyWithProps, TableName extends Table.Name, >( convexDatabaseReader: BaseDatabaseReader< DataModel.ToConvex> >, table: Table.WithName, ): QueryInitializer, TableName> => { const tableName = table.tableName; type DataModel_ = DataModel.FromTables; type ConvexDataModel_ = DataModel.ToConvex; type ThisQueryInitializer = QueryInitializer; type QueryInitializerFunction< FunctionName extends keyof ThisQueryInitializer, > = ThisQueryInitializer[FunctionName]; const getByIndex = < IndexName extends keyof Indexes< DataModel.TableInfoWithName >, >( indexName: IndexName, indexFieldValues: IndexFieldTypesForEq< DataModel.ToConvex, TableName, Indexes>[IndexName] >, ): Effect.Effect< DataModel.DocumentWithName, Document.DocumentDecodeError | GetByIndexFailure > => { const indexFieldPaths: GenericTableIndexes[keyof GenericTableIndexes] = ( table.indexes as GenericTableIndexes )[indexName as keyof GenericTableIndexes]!; return pipe( Effect.promise(() => convexDatabaseReader .query(tableName) .withIndex(indexName, (q) => Array.reduce( indexFieldValues, q, (q_, v, i) => q_.eq(indexFieldPaths[i] as any, v as any) as any, ), ) .unique(), ), Effect.andThen((value) => Effect.fromResult( Result.fromNullishOr( value, () => new GetByIndexFailure({ tableName, indexName: indexName as string, indexFieldValues, }), ), ), ), Effect.andThen(Document.decode(table)), ); }; const get: QueryInitializerFunction<"get"> = (( ...args: Parameters> ) => { if (args.length === 1) { const id = args[0] as GenericId; return getById(convexDatabaseReader, table)(id); } else { const [indexName, ...indexFieldValues] = args; return getByIndex( indexName as keyof Indexes< DataModel.TableInfoWithName >, indexFieldValues, ); } }) as QueryInitializerFunction<"get">; const index: QueryInitializerFunction<"index"> = < IndexName extends keyof Indexes< DataModel.TableInfoWithName >, >( indexName: IndexName, indexRangeOrOrder?: | (( q: IndexRangeBuilder< DataModel.TableInfoWithName_< DataModel_, TableName >["convexDocument"], NamedIndex< DataModel.TableInfoWithName, IndexName > >, ) => IndexRange) | OrderDirection, order?: OrderDirection, ) => { const { applyWithIndex, applyOrder, }: { applyWithIndex: ( queryInitializer: ConvexQueryInitializer< NamedTableInfo >, ) => Query>; applyOrder: ( query: Query>, ) => ConvexOrderedQuery>; } = indexRangeOrOrder === undefined ? { applyWithIndex: (q) => q.withIndex(indexName), applyOrder: (q) => q.order("asc"), } : typeof indexRangeOrOrder === "function" ? order === undefined ? { applyWithIndex: (q) => q.withIndex(indexName, indexRangeOrOrder), applyOrder: (q) => q.order("asc"), } : { applyWithIndex: (q) => q.withIndex(indexName, indexRangeOrOrder), applyOrder: (q) => q.order(order), } : { applyWithIndex: (q) => q.withIndex(indexName), applyOrder: (q) => q.order(indexRangeOrOrder), }; const orderedQuery = pipe( convexDatabaseReader.query(tableName), applyWithIndex, applyOrder, ); return OrderedQuery.make(orderedQuery, table); }; const stream: QueryInitializerFunction<"stream"> = (( indexName: string, indexRangeOrOrder?: | (( q: QueryStreamIndexRange.Builder, ) => QueryStreamIndexRange.QueryStreamIndexRange) | OrderDirection, maybeOrder?: OrderDirection, ) => { const orderDirection = Predicate.isString(indexRangeOrOrder) ? indexRangeOrOrder : (maybeOrder ?? "asc"); // Without a range callback, the leaf scans the entire index. const indexRange = Predicate.isFunction(indexRangeOrOrder) ? indexRangeOrOrder(QueryStreamIndexRange.builder()) : QueryStreamIndexRange.builder(); // The type-level field tuple appends the `_creationTime` tiebreaker, but // the runtime `table.indexes` record stores only the declared fields—append it here. const indexFieldPaths: ReadonlyArray = indexName === "by_id" ? ["_id"] : indexName === "by_creation_time" ? ["_creationTime"] : pipe( Option.fromUndefinedOr( ( table.indexes as ReadonlyRecord> )[indexName], ), // An unknown index name is a defect, not an empty field list: // silently empty fields would make key extraction and range // splitting target the wrong fields. Option.getOrThrowWith( () => new Error( `QueryInitializer.stream: table "${tableName}" has no index named "${indexName}"`, ), ), Array.append("_creationTime"), ); return QueryStream.fromReflection({ reader: convexDatabaseReader as QueryStream.ReflectionReader, table, indexName, indexFieldPaths, indexRange, orderDirection, }); }) as QueryInitializerFunction<"stream">; const search: QueryInitializerFunction<"search"> = ( indexName, searchFilter, ) => OrderedQuery.make( convexDatabaseReader .query(tableName) .withSearchIndex(indexName, searchFilter), table, ); return { get, index, search, stream, }; }; export const getById = >( convexDatabaseReader: BaseDatabaseReader< DataModel.ToConvex> >, table: Table.WithName, ) => (id: GenericId) => pipe( Effect.promise(() => convexDatabaseReader.get(id)), Effect.andThen((value) => Effect.fromResult( Result.fromNullishOr( value, () => new GetByIdFailure({ tableName: table.tableName, id }), ), ), ), Effect.andThen(Document.decode(table)), ); export class GetByIdFailure extends Schema.TaggedError()( "GetByIdFailure", { id: Schema.String, tableName: Schema.String, }, ) { override get message(): string { return Document.documentErrorMessage({ id: this.id, tableName: this.tableName, message: "not found", }); } } export class GetByIndexFailure extends Schema.TaggedError()( "GetByIndexFailure", { tableName: Schema.String, indexName: Schema.String, indexFieldValues: Schema.Array(Schema.Unknown), }, ) { override get message(): string { return `No documents found in table '${this.tableName}' with index '${this.indexName}' and field values '${JSON.stringify( this.indexFieldValues, (_key, value) => (typeof value === "bigint" ? value.toString() : value), )}'`; } }