diff --git a/packages/db/src/errors.ts b/packages/db/src/errors.ts index f62278c56..940084358 100644 --- a/packages/db/src/errors.ts +++ b/packages/db/src/errors.ts @@ -444,6 +444,15 @@ export class FnSelectWithGroupByError extends QueryCompilationError { } } +export class UnsupportedRootScalarSelectError extends QueryCompilationError { + constructor() { + super( + `Top-level scalar select() is not supported by createLiveQueryCollection() or queryOnce(). ` + + `Return an object from .select(), or use the scalar query inside toArray(...) or concat(toArray(...)).`, + ) + } +} + export class HavingRequiresGroupByError extends QueryCompilationError { constructor() { super(`HAVING clause requires GROUP BY clause`) diff --git a/packages/db/src/query/builder/functions.ts b/packages/db/src/query/builder/functions.ts index df1976b3e..84e203589 100644 --- a/packages/db/src/query/builder/functions.ts +++ b/packages/db/src/query/builder/functions.ts @@ -2,7 +2,12 @@ import { Aggregate, Func } from '../ir' import { toExpression } from './ref-proxy.js' import type { BasicExpression } from '../ir' import type { RefProxy } from './ref-proxy.js' -import type { Context, GetResult, RefLeaf } from './types.js' +import type { + Context, + GetRawResult, + RefLeaf, + StringifiableScalar, +} from './types.js' import type { QueryBuilder } from './index.js' type StringRef = @@ -38,8 +43,20 @@ type ComparisonOperandPrimitive = | undefined | null -// Helper type for any expression-like value -type ExpressionLike = BasicExpression | RefProxy | RefLeaf | any +// Helper type for values that can be lowered to expressions. +type ExpressionLike = + | Aggregate + | BasicExpression + | RefProxy + | RefLeaf + | string + | number + | boolean + | bigint + | Date + | null + | undefined + | Array // Helper type to extract the underlying type from various expression types type ExtractType = @@ -277,9 +294,26 @@ export function length( return new Func(`length`, [toExpression(arg)]) as NumericFunctionReturnType } +export function concat( + arg: ToArrayWrapper, +): ConcatToArrayWrapper +export function concat(...args: Array): BasicExpression export function concat( - ...args: Array -): BasicExpression { + ...args: Array> +): BasicExpression | ConcatToArrayWrapper { + const toArrayArg = args.find( + (arg): arg is ToArrayWrapper => arg instanceof ToArrayWrapper, + ) + + if (toArrayArg) { + if (args.length !== 1) { + throw new Error( + `concat(toArray(...)) currently supports only a single toArray(...) argument`, + ) + } + return new ConcatToArrayWrapper(toArrayArg.query) + } + return new Func( `concat`, args.map((arg) => toExpression(arg)), @@ -402,13 +436,20 @@ export const operators = [ export type OperatorName = (typeof operators)[number] -export class ToArrayWrapper { - declare readonly _type: T +export class ToArrayWrapper<_T = unknown> { + declare readonly _type: `toArray` + declare readonly _result: _T + constructor(public readonly query: QueryBuilder) {} +} + +export class ConcatToArrayWrapper<_T = unknown> { + declare readonly _type: `concatToArray` + declare readonly _result: _T constructor(public readonly query: QueryBuilder) {} } export function toArray( query: QueryBuilder, -): ToArrayWrapper> { +): ToArrayWrapper> { return new ToArrayWrapper(query) } diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index ac1bc1582..38acbbfbe 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -3,6 +3,7 @@ import { Aggregate as AggregateExpr, CollectionRef, Func as FuncExpr, + INCLUDES_SCALAR_FIELD, IncludesSubquery, PropRef, QueryRef, @@ -24,11 +25,12 @@ import { isRefProxy, toExpression, } from './ref-proxy.js' -import { ToArrayWrapper } from './functions.js' +import { ConcatToArrayWrapper, ToArrayWrapper } from './functions.js' import type { NamespacedRow, SingleResult } from '../../types.js' import type { Aggregate, BasicExpression, + IncludesMaterialization, JoinClause, OrderBy, OrderByDirection, @@ -44,10 +46,13 @@ import type { JoinOnCallback, MergeContextForJoinCallback, MergeContextWithJoinType, + NonScalarSelectObject, OrderByCallback, OrderByOptions, RefsForContext, ResultTypeFromSelect, + ResultTypeFromSelectValue, + ScalarSelectValue, SchemaFromSource, SelectObject, Source, @@ -489,11 +494,29 @@ export class BaseQueryBuilder { * ``` */ select( - callback: (refs: RefsForContext) => TSelectObject, - ): QueryBuilder>> { + callback: ( + refs: RefsForContext, + ) => NonScalarSelectObject, + ): QueryBuilder>> + select( + callback: (refs: RefsForContext) => TSelectValue, + ): QueryBuilder>> + select( + callback: ( + refs: RefsForContext, + ) => SelectObject | ScalarSelectValue, + ) { const aliases = this._getCurrentAliases() const refProxy = createRefProxy(aliases) as RefsForContext - const selectObject = callback(refProxy) + let selectObject = callback(refProxy) + + // Returning a top-level alias directly is equivalent to spreading it. + // Leaf refs like `row.name` must remain scalar selections. + if (isRefProxy(selectObject) && selectObject.__path.length === 1) { + const sentinelKey = `__SPREAD_SENTINEL__${selectObject.__path[0]}__0` + selectObject = { [sentinelKey]: true } + } + const select = buildNestedSelect(selectObject, aliases) return new BaseQueryBuilder({ @@ -679,7 +702,7 @@ export class BaseQueryBuilder { * // Get countries our users are from * query * .from({ users: usersCollection }) - * .select(({users}) => users.country) + * .select(({users}) => ({ country: users.country })) * .distinct() * ``` */ @@ -709,7 +732,7 @@ export class BaseQueryBuilder { // TODO: enforcing return only one result with also a default orderBy if none is specified // limit: 1, singleResult: true, - }) + }) as any } // Helper methods @@ -772,7 +795,7 @@ export class BaseQueryBuilder { ...builder.query, select: undefined, // remove the select clause if it exists fnSelect: callback, - }) + }) as any }, /** * Filter rows using a function that operates on each row @@ -880,14 +903,21 @@ function buildNestedSelect(obj: any, parentAliases: Array = []): any { continue } if (v instanceof BaseQueryBuilder) { - out[k] = buildIncludesSubquery(v, k, parentAliases, false) + out[k] = buildIncludesSubquery(v, k, parentAliases, `collection`) continue } if (v instanceof ToArrayWrapper) { if (!(v.query instanceof BaseQueryBuilder)) { throw new Error(`toArray() must wrap a subquery builder`) } - out[k] = buildIncludesSubquery(v.query, k, parentAliases, true) + out[k] = buildIncludesSubquery(v.query, k, parentAliases, `array`) + continue + } + if (v instanceof ConcatToArrayWrapper) { + if (!(v.query instanceof BaseQueryBuilder)) { + throw new Error(`concat(toArray(...)) must wrap a subquery builder`) + } + out[k] = buildIncludesSubquery(v.query, k, parentAliases, `concat`) continue } out[k] = buildNestedSelect(v, parentAliases) @@ -937,7 +967,7 @@ function buildIncludesSubquery( childBuilder: BaseQueryBuilder, fieldName: string, parentAliases: Array, - materializeAsArray: boolean, + materialization: IncludesMaterialization, ): IncludesSubquery { const childQuery = childBuilder._getQuery() @@ -1093,14 +1123,45 @@ function buildIncludesSubquery( where: pureChildWhere.length > 0 ? pureChildWhere : undefined, } + const rawChildSelect = modifiedQuery.select as any + const hasObjectSelect = + rawChildSelect === undefined || isPlainObject(rawChildSelect) + let includesQuery = modifiedQuery + let scalarField: string | undefined + + if (materialization === `concat`) { + if (rawChildSelect === undefined || hasObjectSelect) { + throw new Error( + `concat(toArray(...)) for "${fieldName}" requires the subquery to select a scalar value`, + ) + } + } + + if (!hasObjectSelect) { + if (materialization === `collection`) { + throw new Error( + `Includes subquery for "${fieldName}" must select an object when materializing as a Collection`, + ) + } + + scalarField = INCLUDES_SCALAR_FIELD + includesQuery = { + ...modifiedQuery, + select: { + [scalarField]: rawChildSelect, + }, + } + } + return new IncludesSubquery( - modifiedQuery, + includesQuery, parentRef, childRef, fieldName, parentFilters.length > 0 ? parentFilters : undefined, parentProjection, - materializeAsArray, + materialization, + scalarField, ) } diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 4a8eea3a8..1c59db536 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -8,9 +8,9 @@ import type { PropRef, Value, } from '../ir.js' -import type { QueryBuilder } from './index.js' +import type { InitialQueryBuilder, QueryBuilder } from './index.js' import type { VirtualRowProps, WithVirtualProps } from '../../virtual-props.js' -import type { ToArrayWrapper } from './functions.js' +import type { ConcatToArrayWrapper, ToArrayWrapper } from './functions.js' /** * Context - The central state container for query builder operations @@ -50,6 +50,8 @@ export interface Context { > // The result type after select (if select has been called) result?: any + // Whether select/fn.select has been called + hasResult?: true // Single result only (if findOne has been called) singleResult?: boolean } @@ -179,10 +181,22 @@ type SelectValue = | { [key: string]: SelectValue } | Array> | ToArrayWrapper // toArray() wrapped subquery + | ConcatToArrayWrapper // concat(toArray(...)) wrapped subquery | QueryBuilder // includes subquery (produces a child Collection) // Recursive shape for select objects allowing nested projections type SelectShape = { [key: string]: SelectValue | SelectShape } +export type ScalarSelectValue = + | BasicExpression + | Aggregate + | Ref + | RefLeaf + | string + | number + | boolean + | null + | undefined +export type StringifiableScalar = string | number | boolean | null | undefined /** * SelectObject - Wrapper type for select clause objects @@ -192,6 +206,69 @@ type SelectShape = { [key: string]: SelectValue | SelectShape } * messages when invalid selections are attempted. */ export type SelectObject = T +type RefBrandKeys = typeof RefBrand | typeof NullableBrand +type HasNamedSelectKeys = + Exclude extends never ? false : true +type IsScalarSelectLike = T extends BasicExpression | Aggregate + ? true + : T extends string | number | boolean | null | undefined + ? true + : typeof RefBrand extends keyof T + ? HasNamedSelectKeys extends true + ? false + : true + : false +export type NonScalarSelectObject = T extends SelectObject + ? IsScalarSelectLike extends true + ? never + : T + : never + +export type ResultTypeFromSelectValue = + IsAny extends true + ? any + : WithoutRefBrand< + NeedsExtraction extends true + ? ExtractExpressionType + : TSelectValue extends ToArrayWrapper + ? Array + : TSelectValue extends ConcatToArrayWrapper + ? string + : TSelectValue extends QueryBuilder + ? Collection> + : TSelectValue extends Ref + ? ExtractRef + : TSelectValue extends RefLeaf + ? IsNullableRef extends true + ? T | undefined + : T + : TSelectValue extends RefLeaf | undefined + ? T | undefined + : TSelectValue extends RefLeaf | null + ? IsNullableRef< + Exclude + > extends true + ? T | null | undefined + : T | null + : TSelectValue extends Ref | undefined + ? + | ExtractRef> + | undefined + : TSelectValue extends Ref | null + ? ExtractRef> | null + : TSelectValue extends Aggregate + ? T + : TSelectValue extends + | string + | number + | boolean + | null + | undefined + ? TSelectValue + : TSelectValue extends Record + ? ResultTypeFromSelect + : never + > /** * ResultTypeFromSelect - Infers the result type from a select object @@ -229,53 +306,71 @@ export type SelectObject = T * { id: number, name: string, status: 'active', count: 42, profile: { bio: string } } * ``` */ -export type ResultTypeFromSelect = WithoutRefBrand< - Prettify<{ - [K in keyof TSelectObject]: NeedsExtraction extends true - ? ExtractExpressionType - : TSelectObject[K] extends ToArrayWrapper - ? Array - : // includes subquery (bare QueryBuilder) — produces a child Collection - TSelectObject[K] extends QueryBuilder - ? Collection> - : // Ref (full object ref or spread with RefBrand) - recursively process properties - TSelectObject[K] extends Ref - ? ExtractRef - : // RefLeaf (simple property ref like user.name) - TSelectObject[K] extends RefLeaf - ? IsNullableRef extends true - ? T | undefined - : T - : // RefLeaf | undefined (schema-optional field) - TSelectObject[K] extends RefLeaf | undefined - ? T | undefined - : // RefLeaf | null (schema-nullable field) - TSelectObject[K] extends RefLeaf | null - ? IsNullableRef> extends true - ? T | null | undefined - : T | null - : // Ref | undefined (optional object-type schema field) - TSelectObject[K] extends Ref | undefined - ? - | ExtractRef> - | undefined - : // Ref | null (nullable object-type schema field) - TSelectObject[K] extends Ref | null - ? ExtractRef> | null - : TSelectObject[K] extends Aggregate - ? T - : TSelectObject[K] extends - | string - | number - | boolean - | null - | undefined - ? TSelectObject[K] - : TSelectObject[K] extends Record - ? ResultTypeFromSelect - : never - }> -> +export type ResultTypeFromSelect = + IsAny extends true + ? any + : WithoutRefBrand< + Prettify<{ + [K in keyof TSelectObject]: NeedsExtraction< + TSelectObject[K] + > extends true + ? ExtractExpressionType + : TSelectObject[K] extends ToArrayWrapper + ? Array + : TSelectObject[K] extends ConcatToArrayWrapper + ? string + : // includes subquery (bare QueryBuilder) — produces a child Collection + TSelectObject[K] extends QueryBuilder + ? Collection> + : // Ref (full object ref or spread with RefBrand) - recursively process properties + TSelectObject[K] extends Ref + ? ExtractRef + : // RefLeaf (simple property ref like user.name) + TSelectObject[K] extends RefLeaf + ? IsNullableRef extends true + ? T | undefined + : T + : // RefLeaf | undefined (schema-optional field) + TSelectObject[K] extends RefLeaf | undefined + ? T | undefined + : // RefLeaf | null (schema-nullable field) + TSelectObject[K] extends RefLeaf | null + ? IsNullableRef< + Exclude + > extends true + ? T | null | undefined + : T | null + : // Ref | undefined (optional object-type schema field) + TSelectObject[K] extends Ref | undefined + ? + | ExtractRef< + Exclude + > + | undefined + : // Ref | null (nullable object-type schema field) + TSelectObject[K] extends Ref | null + ? ExtractRef< + Exclude + > | null + : TSelectObject[K] extends Aggregate + ? T + : TSelectObject[K] extends + | string + | number + | boolean + | null + | undefined + ? TSelectObject[K] + : TSelectObject[K] extends Record + ? ResultTypeFromSelect + : never + }> + > + +export type SelectResult = + IsPlainObject extends true + ? ResultTypeFromSelect + : ResultTypeFromSelectValue // Extract Ref or subobject with a spread or a Ref type ExtractRef = Prettify>> @@ -386,7 +481,7 @@ export type JoinOnCallback = ( * Example (no GROUP BY): `(row) => row.user.salary > 70000 && row.$selected.user_count > 2` */ export type FunctionalHavingRow = TContext[`schema`] & - (TContext[`result`] extends object ? { $selected: TContext[`result`] } : {}) + (TContext[`hasResult`] extends true ? { $selected: TContext[`result`] } : {}) /** * RefsForContext - Creates ref proxies for all tables/collections in a query context @@ -422,7 +517,7 @@ export type RefsForContext = { : // T is exactly undefined, exactly null, or neither optional nor nullable // Wrap in Ref as-is (includes exact undefined, exact null, and normal types) Ref -} & (TContext[`result`] extends object +} & (TContext[`hasResult`] extends true ? { $selected: Ref } : {}) @@ -586,7 +681,7 @@ type IsNullableRef = typeof NullableBrand extends keyof T ? true : false // Helper type to remove RefBrand and NullableBrand from objects type WithoutRefBrand = - T extends Record + IsPlainObject extends true ? Omit : T @@ -604,6 +699,10 @@ type PreserveSingleResultFlag = [TFlag] extends [true] ? { singleResult: true } : {} +type PreserveHasResultFlag = [TFlag] extends [true] + ? { hasResult: true } + : {} + /** * MergeContextWithJoinType - Creates a new context after a join operation * @@ -649,7 +748,8 @@ export type MergeContextWithJoinType< [K in keyof TNewSchema & string]: TJoinType } result: TContext[`result`] -} & PreserveSingleResultFlag +} & PreserveSingleResultFlag & + PreserveHasResultFlag /** * ApplyJoinOptionalityToMergedSchema - Applies optionality rules when merging schemas @@ -711,6 +811,13 @@ type WithVirtualPropsIfObject = TResult extends object ? WithVirtualProps : TResult +type PrettifyIfPlainObject = IsPlainObject extends true ? Prettify : T +type ResultValue = TContext[`hasResult`] extends true + ? WithVirtualPropsIfObject + : TContext[`hasJoins`] extends true + ? TContext[`schema`] + : TContext[`schema`][TContext[`fromSourceName`]] + /** * GetResult - Determines the final result type of a query * @@ -736,16 +843,46 @@ type WithVirtualPropsIfObject = TResult extends object * The `Prettify` wrapper ensures clean type display in IDEs by flattening * complex intersection types into readable object types. */ +export type GetRawResult = ResultValue + export type GetResult = Prettify< - TContext[`result`] extends object - ? WithVirtualPropsIfObject - : TContext[`hasJoins`] extends true - ? // Optionality is already applied in the schema, just return it - TContext[`schema`] - : // Single table query - return the specific table - TContext[`schema`][TContext[`fromSourceName`]] + ResultValue > +type IsExactlyContext = [Context] extends [TContext] + ? [TContext] extends [Context] + ? true + : false + : false + +type RootScalarResultError = { + readonly __tanstackDbRootQueryError__: `Top-level scalar results are not supported by createLiveQueryCollection() or queryOnce(). Return an object, or use the scalar query inside toArray(...) or concat(toArray(...)).` +} + +export type RootObjectResultConstraint = + IsExactlyContext extends true + ? unknown + : GetResult extends object + ? unknown + : RootScalarResultError + +type ContextFromQueryBuilder> = + TQuery extends QueryBuilder ? TContext : never + +export type RootQueryBuilder> = TQuery & + RootObjectResultConstraint> + +export type RootQueryFn> = ( + q: InitialQueryBuilder, +) => RootQueryBuilder + +export type RootQueryResult = + IsExactlyContext extends true + ? any + : GetResult extends object + ? GetResult + : never + /** * ApplyJoinOptionalityToSchema - Legacy helper for complex join scenarios * @@ -878,7 +1015,7 @@ export type MergeContextForJoinCallback< ? TContext[`joinTypes`] : {} result: TContext[`result`] -} +} & PreserveHasResultFlag /** * WithResult - Updates a context with a new result type after select() @@ -895,8 +1032,9 @@ export type MergeContextForJoinCallback< * result type display cleanly in IDEs. */ export type WithResult = Prettify< - Omit & { - result: Prettify + Omit & { + result: PrettifyIfPlainObject + hasResult: true } > @@ -920,6 +1058,8 @@ type IsPlainObject = T extends unknown : false : false +type IsAny = 0 extends 1 & T ? true : false + /** * JsBuiltIns - List of JavaScript built-ins */ diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index 707f3add2..70786ca8d 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -32,6 +32,7 @@ import type { OrderByOptimizationInfo } from './order-by.js' import type { BasicExpression, CollectionRef, + IncludesMaterialization, QueryIR, QueryRef, } from '../ir.js' @@ -68,8 +69,10 @@ export interface IncludesCompilationResult { childCompilationResult: CompilationResult /** Parent-side projection refs for parent-referencing filters */ parentProjection?: Array - /** When true, the output layer materializes children as Array instead of Collection */ - materializeAsArray: boolean + /** How the output layer materializes the child result on the parent row */ + materialization: IncludesMaterialization + /** Internal field used to unwrap scalar child selects */ + scalarField?: string } /** @@ -418,7 +421,8 @@ export function compileQuery( ), childCompilationResult: childResult, parentProjection: subquery.parentProjection, - materializeAsArray: subquery.materializeAsArray, + materialization: subquery.materialization, + scalarField: subquery.scalarField, }) // Capture routing function for INCLUDES_ROUTING tagging diff --git a/packages/db/src/query/ir.ts b/packages/db/src/query/ir.ts index c115974b2..a1d9d848e 100644 --- a/packages/db/src/query/ir.ts +++ b/packages/db/src/query/ir.ts @@ -25,6 +25,10 @@ export interface QueryIR { fnHaving?: Array<(row: NamespacedRow) => any> } +export type IncludesMaterialization = `collection` | `array` | `concat` + +export const INCLUDES_SCALAR_FIELD = `__includes_scalar__` + export type From = CollectionRef | QueryRef export type Select = { @@ -141,7 +145,8 @@ export class IncludesSubquery extends BaseExpression { public fieldName: string, // Result field name (e.g., "issues") public parentFilters?: Array, // WHERE clauses referencing parent aliases (applied post-join) public parentProjection?: Array, // Parent field refs used by parentFilters - public materializeAsArray: boolean = false, // When true, parent gets Array instead of Collection + public materialization: IncludesMaterialization = `collection`, + public scalarField?: string, ) { super() } diff --git a/packages/db/src/query/live-query-collection.ts b/packages/db/src/query/live-query-collection.ts index 47eceac15..8649bc0bc 100644 --- a/packages/db/src/query/live-query-collection.ts +++ b/packages/db/src/query/live-query-collection.ts @@ -6,7 +6,11 @@ import { } from './live/collection-registry.js' import type { LiveQueryCollectionUtils } from './live/collection-config-builder.js' import type { LiveQueryCollectionConfig } from './live/types.js' -import type { InitialQueryBuilder, QueryBuilder } from './builder/index.js' +import type { + ExtractContext, + InitialQueryBuilder, + QueryBuilder, +} from './builder/index.js' import type { Collection } from '../collection/index.js' import type { CollectionConfig, @@ -15,7 +19,13 @@ import type { SingleResult, UtilsRecord, } from '../types.js' -import type { Context, GetResult } from './builder/types.js' +import type { + Context, + RootObjectResultConstraint, + RootQueryBuilder, + RootQueryFn, + RootQueryResult, +} from './builder/types.js' type CollectionConfigForContext< TContext extends Context, @@ -60,10 +70,13 @@ type CollectionForContext< * @returns Collection options that can be passed to createCollection */ export function liveQueryCollectionOptions< - TContext extends Context, - TResult extends object = GetResult, + TQuery extends QueryBuilder, + TContext extends Context = ExtractContext, + TResult extends object = RootQueryResult, >( - config: LiveQueryCollectionConfig, + config: LiveQueryCollectionConfig & { + query: RootQueryFn | RootQueryBuilder + }, ): CollectionConfigForContext & { utils: LiveQueryCollectionUtils } { @@ -113,34 +126,42 @@ export function liveQueryCollectionOptions< // Overload 1: Accept just the query function export function createLiveQueryCollection< - TContext extends Context, - TResult extends object = GetResult, + TQueryFn extends (q: InitialQueryBuilder) => QueryBuilder, + TQuery extends QueryBuilder = ReturnType, >( - query: (q: InitialQueryBuilder) => QueryBuilder, -): CollectionForContext & { + query: TQueryFn & RootQueryFn, +): CollectionForContext< + ExtractContext, + RootQueryResult> +> & { utils: LiveQueryCollectionUtils } // Overload 2: Accept full config object with optional utilities export function createLiveQueryCollection< - TContext extends Context, - TResult extends object = GetResult, + TQuery extends QueryBuilder, + TContext extends Context = ExtractContext, TUtils extends UtilsRecord = {}, >( - config: LiveQueryCollectionConfig & { utils?: TUtils }, -): CollectionForContext & { + config: LiveQueryCollectionConfig> & { + query: RootQueryFn | RootQueryBuilder + utils?: TUtils + }, +): CollectionForContext> & { utils: LiveQueryCollectionUtils & TUtils } // Implementation export function createLiveQueryCollection< TContext extends Context, - TResult extends object = GetResult, + TResult extends object = RootQueryResult, TUtils extends UtilsRecord = {}, >( configOrQuery: | (LiveQueryCollectionConfig & { utils?: TUtils }) - | ((q: InitialQueryBuilder) => QueryBuilder), + | (( + q: InitialQueryBuilder, + ) => QueryBuilder & RootObjectResultConstraint), ): CollectionForContext & { utils: LiveQueryCollectionUtils & TUtils } { @@ -150,9 +171,11 @@ export function createLiveQueryCollection< const config: LiveQueryCollectionConfig = { query: configOrQuery as ( q: InitialQueryBuilder, - ) => QueryBuilder, + ) => QueryBuilder & RootObjectResultConstraint, } - const options = liveQueryCollectionOptions(config) + // The implementation accepts both overload shapes, but TypeScript cannot + // preserve the overload-specific query-builder inference through this branch. + const options = liveQueryCollectionOptions(config as any) return bridgeToCreateCollection(options) as CollectionForContext< TContext, TResult @@ -163,7 +186,9 @@ export function createLiveQueryCollection< TContext, TResult > & { utils?: TUtils } - const options = liveQueryCollectionOptions(config) + // Same overload implementation limitation as above: the config has already + // been validated by the public signatures, but the branch loses that precision. + const options = liveQueryCollectionOptions(config as any) // Merge custom utils if provided, preserving the getBuilder() method for dependency tracking if (config.utils) { diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 8ac7307cd..79e6f2bd0 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -36,7 +36,12 @@ import type { UtilsRecord, } from '../../types.js' import type { Context, GetResult } from '../builder/types.js' -import type { BasicExpression, PropRef, QueryIR } from '../ir.js' +import type { + BasicExpression, + IncludesMaterialization, + PropRef, + QueryIR, +} from '../ir.js' import type { LazyCollectionCallbacks } from '../compiler/joins.js' import type { Changes, @@ -162,7 +167,10 @@ export class CollectionConfigBuilder< // Generate a unique ID if not provided this.id = config.id || `live-query-${++liveQueryCollectionCounter}` - this.query = buildQueryFromConfig(config) + this.query = buildQueryFromConfig({ + query: config.query, + requireObjectResult: true, + }) this.collections = extractCollectionsFromQuery(this.query) const collectionAliasesById = extractCollectionAliases(this.query) @@ -825,7 +833,8 @@ export class CollectionConfigBuilder< fieldName: entry.fieldName, childCorrelationField: entry.childCorrelationField, hasOrderBy: entry.hasOrderBy, - materializeAsArray: entry.materializeAsArray, + materialization: entry.materialization, + scalarField: entry.scalarField, childRegistry: new Map(), pendingChildChanges: new Map(), correlationToParentKeys: new Map(), @@ -1144,8 +1153,10 @@ type IncludesOutputState = { childCorrelationField: PropRef /** Whether the child query has an ORDER BY clause */ hasOrderBy: boolean - /** When true, parent gets Array instead of Collection */ - materializeAsArray: boolean + /** How the child result is materialized on the parent row */ + materialization: IncludesMaterialization + /** Internal field used to unwrap scalar child selects */ + scalarField?: string /** Maps correlation key value → child Collection entry */ childRegistry: Map /** Pending child changes: correlationKey → Map */ @@ -1169,6 +1180,40 @@ type ChildCollectionEntry = { includesStates?: Array } +function materializesInline(state: IncludesOutputState): boolean { + return state.materialization !== `collection` +} + +function materializeIncludedValue( + state: IncludesOutputState, + entry: ChildCollectionEntry | undefined, +): unknown { + if (!entry) { + if (state.materialization === `array`) { + return [] + } + if (state.materialization === `concat`) { + return `` + } + return undefined + } + + if (state.materialization === `collection`) { + return entry.collection + } + + const rows = [...entry.collection.toArray] + const values = state.scalarField + ? rows.map((row) => row?.[state.scalarField!]) + : rows + + if (state.materialization === `array`) { + return values + } + + return values.map((value) => String(value ?? ``)).join(``) +} + /** * Sets up shared buffers for nested includes pipelines. * Instead of writing directly into a single shared IncludesOutputState, @@ -1252,7 +1297,8 @@ function createPerEntryIncludesStates( fieldName: setup.compilationResult.fieldName, childCorrelationField: setup.compilationResult.childCorrelationField, hasOrderBy: setup.compilationResult.hasOrderBy, - materializeAsArray: setup.compilationResult.materializeAsArray, + materialization: setup.compilationResult.materialization, + scalarField: setup.compilationResult.scalarField, childRegistry: new Map(), pendingChildChanges: new Map(), correlationToParentKeys: new Map(), @@ -1535,15 +1581,11 @@ function flushIncludesState( } parentKeys.add(parentKey) - // Attach child Collection (or array snapshot for toArray) to the parent result - const childValue = state.materializeAsArray - ? [...state.childRegistry.get(routingKey)!.collection.toArray] - : state.childRegistry.get(routingKey)!.collection - if (state.materializeAsArray) { - parentResult[state.fieldName] = childValue - } else { - parentResult[state.fieldName] = childValue - } + const childValue = materializeIncludedValue( + state, + state.childRegistry.get(routingKey), + ) + parentResult[state.fieldName] = childValue // Parent rows may already be materialized in the live collection by the // time includes state is flushed, so update the stored row as well. @@ -1556,8 +1598,8 @@ function flushIncludesState( } } - // Track affected correlation keys for toArray re-emit (before clearing pendingChildChanges) - const affectedCorrelationKeys = state.materializeAsArray + // Track affected correlation keys for inline materializations before clearing child changes. + const affectedCorrelationKeys = materializesInline(state) ? new Set(state.pendingChildChanges.keys()) : null @@ -1582,9 +1624,7 @@ function flushIncludesState( state.childRegistry.set(correlationKey, entry) } - // For non-toArray: attach the child Collection to ANY parent that has this correlation key - // For toArray: skip — the array snapshot is set during re-emit below - if (!state.materializeAsArray) { + if (state.materialization === `collection`) { attachChildCollectionToParent( parentCollection, state.fieldName, @@ -1658,18 +1698,18 @@ function flushIncludesState( } } - // For toArray entries: re-emit affected parents with updated array snapshots. + // For inline materializations: re-emit affected parents with updated snapshots. // We mutate items in-place (so collection.get() reflects changes immediately) // and emit UPDATE events directly. We bypass the sync methods because // commitPendingTransactions compares previous vs new visible state using // deepEquals, but in-place mutation means both sides reference the same // object, so the comparison always returns true and suppresses the event. - const toArrayReEmitKeys = state.materializeAsArray + const inlineReEmitKeys = materializesInline(state) ? new Set([...(affectedCorrelationKeys || []), ...dirtyFromBuffers]) : null - if (parentSyncMethods && toArrayReEmitKeys && toArrayReEmitKeys.size > 0) { + if (parentSyncMethods && inlineReEmitKeys && inlineReEmitKeys.size > 0) { const events: Array> = [] - for (const correlationKey of toArrayReEmitKeys) { + for (const correlationKey of inlineReEmitKeys) { const parentKeys = state.correlationToParentKeys.get(correlationKey) if (!parentKeys) continue const entry = state.childRegistry.get(correlationKey) @@ -1679,9 +1719,7 @@ function flushIncludesState( const key = parentSyncMethods.collection.getKeyFromItem(item) // Capture previous value before in-place mutation const previousValue = { ...item } - if (entry) { - item[state.fieldName] = [...entry.collection.toArray] - } + item[state.fieldName] = materializeIncludedValue(state, entry) events.push({ type: `update`, key, diff --git a/packages/db/src/query/live/types.ts b/packages/db/src/query/live/types.ts index 04d0b389f..118015bd6 100644 --- a/packages/db/src/query/live/types.ts +++ b/packages/db/src/query/live/types.ts @@ -5,7 +5,11 @@ import type { StringCollationConfig, } from '../../types.js' import type { InitialQueryBuilder, QueryBuilder } from '../builder/index.js' -import type { Context, GetResult } from '../builder/types.js' +import type { + Context, + RootObjectResultConstraint, + RootQueryResult, +} from '../builder/types.js' export type Changes = { deletes: number @@ -54,7 +58,7 @@ export type FullSyncState = Required> & */ export interface LiveQueryCollectionConfig< TContext extends Context, - TResult extends object = GetResult & object, + TResult extends object = RootQueryResult, > { /** * Unique identifier for the collection @@ -66,8 +70,10 @@ export interface LiveQueryCollectionConfig< * Query builder function that defines the live query */ query: - | ((q: InitialQueryBuilder) => QueryBuilder) - | QueryBuilder + | (( + q: InitialQueryBuilder, + ) => QueryBuilder & RootObjectResultConstraint) + | (QueryBuilder & RootObjectResultConstraint) /** * Function to extract the key from result items diff --git a/packages/db/src/query/live/utils.ts b/packages/db/src/query/live/utils.ts index 765356606..6f37a0587 100644 --- a/packages/db/src/query/live/utils.ts +++ b/packages/db/src/query/live/utils.ts @@ -1,4 +1,5 @@ import { MultiSet, serializeValue } from '@tanstack/db-ivm' +import { UnsupportedRootScalarSelectError } from '../../errors.js' import { normalizeOrderByPaths } from '../compiler/expressions.js' import { buildQuery, getQueryIR } from '../builder/index.js' import { IncludesSubquery } from '../ir.js' @@ -191,12 +192,23 @@ export function buildQueryFromConfig(config: { query: | ((q: InitialQueryBuilder) => QueryBuilder) | QueryBuilder + requireObjectResult?: boolean }): QueryIR { // Build the query using the provided query builder function or instance - if (typeof config.query === `function`) { - return buildQuery(config.query) + const query = + typeof config.query === `function` + ? buildQuery(config.query) + : getQueryIR(config.query) + + if ( + config.requireObjectResult && + query.select && + !isNestedSelectObject(query.select) + ) { + throw new UnsupportedRootScalarSelectError() } - return getQueryIR(config.query) + + return query } /** diff --git a/packages/db/src/query/query-once.ts b/packages/db/src/query/query-once.ts index 319f4fc9a..668f9682c 100644 --- a/packages/db/src/query/query-once.ts +++ b/packages/db/src/query/query-once.ts @@ -1,6 +1,16 @@ import { createLiveQueryCollection } from './live-query-collection.js' -import type { InitialQueryBuilder, QueryBuilder } from './builder/index.js' -import type { Context, InferResultType } from './builder/types.js' +import type { + ExtractContext, + InitialQueryBuilder, + QueryBuilder, +} from './builder/index.js' +import type { + Context, + InferResultType, + RootObjectResultConstraint, + RootQueryBuilder, + RootQueryFn, +} from './builder/types.js' /** * Configuration options for queryOnce @@ -10,8 +20,10 @@ export interface QueryOnceConfig { * Query builder function that defines the query */ query: - | ((q: InitialQueryBuilder) => QueryBuilder) - | QueryBuilder + | (( + q: InitialQueryBuilder, + ) => QueryBuilder & RootObjectResultConstraint) + | (QueryBuilder & RootObjectResultConstraint) // Future: timeout, signal, etc. } @@ -44,9 +56,12 @@ export interface QueryOnceConfig { * ) * ``` */ -export function queryOnce( - queryFn: (q: InitialQueryBuilder) => QueryBuilder, -): Promise> +export function queryOnce< + TQueryFn extends (q: InitialQueryBuilder) => QueryBuilder, + TQuery extends QueryBuilder = ReturnType, +>( + queryFn: TQueryFn & RootQueryFn, +): Promise>> // Overload 2: Config object form returning array (non-single result) /** @@ -65,15 +80,19 @@ export function queryOnce( * }) * ``` */ -export function queryOnce( - config: QueryOnceConfig, -): Promise> +export function queryOnce>( + config: QueryOnceConfig> & { + query: RootQueryFn | RootQueryBuilder + }, +): Promise>> // Implementation export async function queryOnce( configOrQuery: | QueryOnceConfig - | ((q: InitialQueryBuilder) => QueryBuilder), + | (( + q: InitialQueryBuilder, + ) => QueryBuilder & RootObjectResultConstraint), ): Promise> { // Normalize input const config: QueryOnceConfig = diff --git a/packages/db/tests/query/builder/functions.test.ts b/packages/db/tests/query/builder/functions.test.ts index abc6391c9..cd40ce2e4 100644 --- a/packages/db/tests/query/builder/functions.test.ts +++ b/packages/db/tests/query/builder/functions.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import { CollectionImpl } from '../../../src/collection/index.js' import { Query, getQueryIR } from '../../../src/query/builder/index.js' +import { + INCLUDES_SCALAR_FIELD, + IncludesSubquery, +} from '../../../src/query/ir.js' import { add, and, @@ -22,6 +26,7 @@ import { not, or, sum, + toArray, upper, } from '../../../src/query/builder/functions.js' @@ -191,6 +196,36 @@ describe(`QueryBuilder Functions`, () => { expect((select.full_name as any).name).toBe(`concat`) }) + it(`concat(toArray(subquery)) lowers to concat includes materialization`, () => { + const query = new Query() + .from({ manager: employeesCollection }) + .select(({ manager }) => ({ + report_names: concat( + toArray( + new Query() + .from({ employee: employeesCollection }) + .where(({ employee }) => eq(employee.department_id, manager.id)) + .select(({ employee }) => employee.name), + ), + ), + })) + + const builtQuery = getQueryIR(query) + const select = builtQuery.select! + const reportNames = select.report_names + + expect(reportNames).toBeInstanceOf(IncludesSubquery) + expect((reportNames as IncludesSubquery).materialization).toBe(`concat`) + expect((reportNames as IncludesSubquery).scalarField).toBe( + INCLUDES_SCALAR_FIELD, + ) + expect((reportNames as IncludesSubquery).query.select).toEqual({ + [INCLUDES_SCALAR_FIELD]: expect.objectContaining({ + type: `ref`, + }), + }) + }) + it(`coalesce function works`, () => { const query = new Query() .from({ employees: employeesCollection }) diff --git a/packages/db/tests/query/includes.test-d.ts b/packages/db/tests/query/includes.test-d.ts index 7d5b97f3f..48e36ba52 100644 --- a/packages/db/tests/query/includes.test-d.ts +++ b/packages/db/tests/query/includes.test-d.ts @@ -1,7 +1,10 @@ import { describe, expectTypeOf, test } from 'vitest' import { + Query, + concat, createLiveQueryCollection, eq, + queryOnce, toArray, } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' @@ -25,6 +28,18 @@ type Comment = { body: string } +type Message = { + id: number + role: string +} + +type Chunk = { + id: number + messageId: number + text: string + timestamp: number +} + function createProjectsCollection() { return createCollection( mockSyncCollectionOptions({ @@ -55,10 +70,32 @@ function createCommentsCollection() { ) } +function createMessagesCollection() { + return createCollection( + mockSyncCollectionOptions({ + id: `includes-type-messages`, + getKey: (m) => m.id, + initialData: [], + }), + ) +} + +function createChunksCollection() { + return createCollection( + mockSyncCollectionOptions({ + id: `includes-type-chunks`, + getKey: (c) => c.id, + initialData: [], + }), + ) +} + describe(`includes subquery types`, () => { const projects = createProjectsCollection() const issues = createIssuesCollection() const comments = createCommentsCollection() + const messages = createMessagesCollection() + const chunks = createChunksCollection() describe(`Collection includes`, () => { test(`includes with select infers child result as Collection`, () => { @@ -265,5 +302,123 @@ describe(`includes subquery types`, () => { }> >() }) + + test(`toArray supports scalar child subquery selects`, () => { + const collection = createLiveQueryCollection((q) => + q.from({ m: messages }).select(({ m }) => ({ + id: m.id, + contentParts: toArray( + q + .from({ c: chunks }) + .where(({ c }) => eq(c.messageId, m.id)) + .orderBy(({ c }) => c.timestamp) + .select(({ c }) => c.text), + ), + })), + ) + + const result = collection.toArray[0]! + expectTypeOf(result).toMatchTypeOf< + WithVirtualProps<{ + id: number + contentParts: Array + }> + >() + }) + + test(`concat(toArray(scalar subquery)) infers string`, () => { + const collection = createLiveQueryCollection((q) => + q.from({ m: messages }).select(({ m }) => ({ + id: m.id, + content: concat( + toArray( + q + .from({ c: chunks }) + .where(({ c }) => eq(c.messageId, m.id)) + .orderBy(({ c }) => c.timestamp) + .select(({ c }) => c.text), + ), + ), + })), + ) + + const result = collection.toArray[0]! + const content: string = result.content + expectTypeOf(result.id).toEqualTypeOf() + expectTypeOf(content).toEqualTypeOf() + }) + + test(`scalar-selecting builders remain composable for toArray`, () => { + const collection = createLiveQueryCollection((q) => + q.from({ m: messages }).select(({ m }) => { + const contentPartsQuery = q + .from({ c: chunks }) + .where(({ c }) => eq(c.messageId, m.id)) + .orderBy(({ c }) => c.timestamp) + .select(({ c }) => c.text) + + return { + id: m.id, + contentParts: toArray(contentPartsQuery), + } + }), + ) + + const result = collection.toArray[0]! + expectTypeOf(result.contentParts[0]!).toEqualTypeOf() + }) + + test(`returning an alias directly infers the full row shape`, () => { + const collection = createLiveQueryCollection((q) => + q.from({ m: messages }).select(({ m }) => m), + ) + + const result = collection.toArray[0]! + expectTypeOf(result).toMatchTypeOf>() + expectTypeOf(result.role).toEqualTypeOf() + }) + + test(`concat(toArray(...)) rejects non-scalar child queries`, () => { + createLiveQueryCollection((q) => + q.from({ m: messages }).select(({ m }) => ({ + id: m.id, + content: concat( + // @ts-expect-error - concat(toArray(...)) requires a scalar child select + toArray( + q + .from({ c: chunks }) + .where(({ c }) => eq(c.messageId, m.id)) + .select(({ c }) => ({ + text: c.text, + })), + ), + ), + })), + ) + + createLiveQueryCollection((q) => + q.from({ m: messages }).select(({ m }) => ({ + id: m.id, + content: concat( + // @ts-expect-error - concat(toArray(...)) requires the child query result to be scalar + toArray( + q.from({ c: chunks }).where(({ c }) => eq(c.messageId, m.id)), + ), + ), + })), + ) + }) + + test(`root consumers reject top-level scalar select builders`, () => { + const scalarRootQuery = new Query() + .from({ m: messages }) + .select(({ m }) => m.role) + + // @ts-expect-error - top-level scalar select is not supported for live query collections + createLiveQueryCollection({ query: scalarRootQuery }) + + // @ts-expect-error - top-level scalar select is not supported for queryOnce + queryOnce({ query: scalarRootQuery }) + }) }) }) diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index 99c61907a..727050647 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { and, + concat, count, createLiveQueryCollection, eq, @@ -158,6 +159,138 @@ describe(`includes subqueries`, () => { ) } + describe(`scalar includes materialization`, () => { + type Message = { + id: number + role: string + } + + type Chunk = { + id: number + messageId: number + text: string + timestamp: number + } + + const sampleMessages: Array = [ + { id: 1, role: `assistant` }, + { id: 2, role: `user` }, + ] + + const sampleChunks: Array = [ + { id: 10, messageId: 1, text: `world`, timestamp: 3 }, + { id: 11, messageId: 1, text: `Hello`, timestamp: 1 }, + { id: 12, messageId: 1, text: ` `, timestamp: 2 }, + { id: 20, messageId: 2, text: `Question`, timestamp: 1 }, + ] + + function createMessagesCollection() { + return createCollection( + mockSyncCollectionOptions({ + id: `includes-messages`, + getKey: (message) => message.id, + initialData: sampleMessages, + }), + ) + } + + function createChunksCollection() { + return createCollection( + mockSyncCollectionOptions({ + id: `includes-chunks`, + getKey: (chunk) => chunk.id, + initialData: sampleChunks, + }), + ) + } + + it(`toArray unwraps scalar child selects into scalar arrays`, async () => { + const messages = createMessagesCollection() + const chunks = createChunksCollection() + + const collection = createLiveQueryCollection((q) => + q.from({ m: messages }).select(({ m }) => ({ + id: m.id, + contentParts: toArray( + q + .from({ c: chunks }) + .where(({ c }) => eq(c.messageId, m.id)) + .orderBy(({ c }) => c.timestamp) + .select(({ c }) => c.text), + ), + })), + ) + + await collection.preload() + + expect((collection.get(1) as any).contentParts).toEqual([ + `Hello`, + ` `, + `world`, + ]) + expect((collection.get(2) as any).contentParts).toEqual([`Question`]) + }) + + it(`concat(toArray(subquery.select(...))) materializes and re-emits a string`, async () => { + const messages = createMessagesCollection() + const chunks = createChunksCollection() + + const collection = createLiveQueryCollection((q) => + q.from({ m: messages }).select(({ m }) => ({ + id: m.id, + role: m.role, + content: concat( + toArray( + q + .from({ c: chunks }) + .where(({ c }) => eq(c.messageId, m.id)) + .orderBy(({ c }) => c.timestamp) + .select(({ c }) => c.text), + ), + ), + })), + ) + + await collection.preload() + + expect((collection.get(1) as any).content).toBe(`Hello world`) + expect((collection.get(2) as any).content).toBe(`Question`) + + const changeCallback = vi.fn() + const subscription = collection.subscribeChanges(changeCallback, { + includeInitialState: false, + }) + changeCallback.mockClear() + + chunks.utils.begin() + chunks.utils.write({ + type: `insert`, + value: { id: 13, messageId: 1, text: `!`, timestamp: 4 }, + }) + chunks.utils.commit() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(changeCallback).toHaveBeenCalled() + expect((collection.get(1) as any).content).toBe(`Hello world!`) + expect((collection.get(2) as any).content).toBe(`Question`) + + subscription.unsubscribe() + }) + + it(`top-level scalar select throws at root consumers`, () => { + const messages = createMessagesCollection() + + expect(() => + (createLiveQueryCollection as any)((q: any) => + q.from({ m: messages }).select(({ m }: any) => m.role), + ), + ).toThrow( + `Top-level scalar select() is not supported by createLiveQueryCollection() or queryOnce().`, + ) + }) + }) + describe(`basic includes`, () => { it(`produces child Collections on parent rows`, async () => { const collection = buildIncludesQuery() diff --git a/packages/db/tests/query/select-spread.test.ts b/packages/db/tests/query/select-spread.test.ts index e87028db0..a902d52ff 100644 --- a/packages/db/tests/query/select-spread.test.ts +++ b/packages/db/tests/query/select-spread.test.ts @@ -108,6 +108,20 @@ describe(`select spreads (runtime)`, () => { expect(stripVirtualProps(collection.get(1))).toEqual(initialMessages[0]) }) + it(`returning the source alias directly projects the full row`, async () => { + const collection = createLiveQueryCollection((q) => + q.from({ message: messagesCollection }).select(({ message }) => message), + ) + await collection.preload() + + const results = Array.from(collection.values()) + expect(results).toHaveLength(2) + expect(results.map((row) => stripVirtualProps(row))).toEqual( + initialMessages, + ) + expect(stripVirtualProps(collection.get(1))).toEqual(initialMessages[0]) + }) + it(`spread + computed fields merges fields with correct values`, async () => { const collection = createLiveQueryCollection((q) => q.from({ message: messagesCollection }).select(({ message }) => ({ @@ -203,6 +217,21 @@ describe(`select spreads (runtime)`, () => { expect(r1.meta.tags).toEqual([`a`, `b`]) }) + it(`returning an alias directly preserves nested object fields intact`, async () => { + const messagesNested = createMessagesWithMetaCollection() + const collection = createLiveQueryCollection((q) => + q.from({ m: messagesNested }).select(({ m }) => m), + ) + await collection.preload() + + const results = Array.from(collection.values()) + expect(results.map((row) => stripVirtualProps(row))).toEqual(nestedMessages) + + const r1 = results.find((r) => r.id === 1) as MessageWithMeta + expect(r1.meta.author.name).toBe(`sam`) + expect(r1.meta.tags).toEqual([`a`, `b`]) + }) + it(`repeating the same alias spread multiple times uses last-wins for all fields`, async () => { const collection = createLiveQueryCollection((q) => q.from({ message: messagesCollection }).select(({ message }) => ({ @@ -283,4 +312,26 @@ describe(`select spreads (runtime)`, () => { const r1 = Array.from(collection.values()).find((r) => r.id === 1) as any expect(r1.meta.author).toEqual({ name: `sam`, rating: 5 }) }) + + it(`returning an alias directly keeps live updates working`, async () => { + const collection = createLiveQueryCollection((q) => + q.from({ message: messagesCollection }).select(({ message }) => message), + ) + await collection.preload() + + messagesCollection.utils.begin() + messagesCollection.utils.write({ + type: `insert`, + value: { id: 3, text: `test`, user: `alex` }, + }) + messagesCollection.utils.commit() + + const results = Array.from(collection.values()) + expect(results).toHaveLength(3) + expect(stripVirtualProps(collection.get(3))).toEqual({ + id: 3, + text: `test`, + user: `alex`, + }) + }) })