diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index dcdfe6c0e..3a04550a5 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -42,6 +42,9 @@ import type { MergeContextWithJoinType, OrderByCallback, OrderByOptions, + QueryRefsForContext, + QuerySchemaFromSource, + QueryWhereCallback, RefsForContext, ResultTypeFromSelect, SchemaFromSource, @@ -138,6 +141,7 @@ export class BaseQueryBuilder { ): QueryBuilder<{ baseSchema: SchemaFromSource schema: SchemaFromSource + querySchema: QuerySchemaFromSource fromSourceName: keyof TSource & string hasJoins: false }> { @@ -182,11 +186,20 @@ export class BaseQueryBuilder { >( source: TSource, onCallback: JoinOnCallback< - MergeContextForJoinCallback> + MergeContextForJoinCallback< + TContext, + SchemaFromSource, + QuerySchemaFromSource + > >, type: TJoinType = `left` as TJoinType, ): QueryBuilder< - MergeContextWithJoinType, TJoinType> + MergeContextWithJoinType< + TContext, + SchemaFromSource, + TJoinType, + QuerySchemaFromSource + > > { const [alias, from] = this._createRefForSource(source, `join clause`) @@ -194,7 +207,11 @@ export class BaseQueryBuilder { const currentAliases = this._getCurrentAliases() const newAliases = [...currentAliases, alias] const refProxy = createRefProxy(newAliases) as RefsForContext< - MergeContextForJoinCallback> + MergeContextForJoinCallback< + TContext, + SchemaFromSource, + QuerySchemaFromSource + > > // Get the join condition expression @@ -249,10 +266,19 @@ export class BaseQueryBuilder { leftJoin( source: TSource, onCallback: JoinOnCallback< - MergeContextForJoinCallback> + MergeContextForJoinCallback< + TContext, + SchemaFromSource, + QuerySchemaFromSource + > >, ): QueryBuilder< - MergeContextWithJoinType, `left`> + MergeContextWithJoinType< + TContext, + SchemaFromSource, + `left`, + QuerySchemaFromSource + > > { return this.join(source, onCallback, `left`) } @@ -275,10 +301,19 @@ export class BaseQueryBuilder { rightJoin( source: TSource, onCallback: JoinOnCallback< - MergeContextForJoinCallback> + MergeContextForJoinCallback< + TContext, + SchemaFromSource, + QuerySchemaFromSource + > >, ): QueryBuilder< - MergeContextWithJoinType, `right`> + MergeContextWithJoinType< + TContext, + SchemaFromSource, + `right`, + QuerySchemaFromSource + > > { return this.join(source, onCallback, `right`) } @@ -301,10 +336,19 @@ export class BaseQueryBuilder { innerJoin( source: TSource, onCallback: JoinOnCallback< - MergeContextForJoinCallback> + MergeContextForJoinCallback< + TContext, + SchemaFromSource, + QuerySchemaFromSource + > >, ): QueryBuilder< - MergeContextWithJoinType, `inner`> + MergeContextWithJoinType< + TContext, + SchemaFromSource, + `inner`, + QuerySchemaFromSource + > > { return this.join(source, onCallback, `inner`) } @@ -327,10 +371,19 @@ export class BaseQueryBuilder { fullJoin( source: TSource, onCallback: JoinOnCallback< - MergeContextForJoinCallback> + MergeContextForJoinCallback< + TContext, + SchemaFromSource, + QuerySchemaFromSource + > >, ): QueryBuilder< - MergeContextWithJoinType, `full`> + MergeContextWithJoinType< + TContext, + SchemaFromSource, + `full`, + QuerySchemaFromSource + > > { return this.join(source, onCallback, `full`) } @@ -363,9 +416,9 @@ export class BaseQueryBuilder { * .where(({users}) => eq(users.active, true)) * ``` */ - where(callback: WhereCallback): QueryBuilder { + where(callback: QueryWhereCallback): QueryBuilder { const aliases = this._getCurrentAliases() - const refProxy = createRefProxy(aliases) as RefsForContext + const refProxy = createRefProxy(aliases) as QueryRefsForContext const expression = callback(refProxy) // Validate that the callback returned a valid expression @@ -691,7 +744,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 @@ -754,7 +807,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 @@ -780,7 +833,7 @@ export class BaseQueryBuilder { ...(builder.query.fnWhere || []), callback as (row: NamespacedRow) => any, ], - }) + }) as any }, /** * Filter grouped rows using a function that operates on each aggregated row @@ -808,7 +861,7 @@ export class BaseQueryBuilder { ...(builder.query.fnHaving || []), callback as (row: NamespacedRow) => any, ], - }) + }) as any }, } } diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 11360dd82..a47e8e865 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -37,6 +37,9 @@ export interface Context { baseSchema: ContextSchema // The current schema available (includes joined collections) schema: ContextSchema + // Optional schema used for query refs in where/orderBy callbacks. + // When omitted, refs default to the main `schema`. + querySchema?: ContextSchema // the name of the source that was used in the from clause fromSourceName: string // Whether this query has joins @@ -85,6 +88,21 @@ export type Source = { export type InferCollectionType = T extends CollectionImpl ? TOutput : never +declare const QuerySchemaBrand: unique symbol + +export type CollectionWithQuerySchema< + TCollection extends CollectionImpl, + TQuerySchema extends object, +> = TCollection & { + readonly [QuerySchemaBrand]: TQuerySchema +} + +type InferCollectionQuerySchema = T extends { + readonly [QuerySchemaBrand]: infer TQuerySchema +} + ? TQuerySchema + : InferCollectionType + /** * SchemaFromSource - Converts a Source definition into a ContextSchema * @@ -103,6 +121,14 @@ export type SchemaFromSource = Prettify<{ : never }> +export type QuerySchemaFromSource = Prettify<{ + [K in keyof T]: T[K] extends CollectionImpl + ? InferCollectionQuerySchema + : T[K] extends QueryBuilder + ? GetQueryResult + : never +}> + /** * GetAliases - Extracts all table aliases available in a query context * @@ -111,6 +137,11 @@ export type SchemaFromSource = Prettify<{ */ export type GetAliases = keyof TContext[`schema`] +type ResolveQuerySchema = + TContext[`querySchema`] extends ContextSchema + ? TContext[`querySchema`] + : TContext[`schema`] + /** * WhereCallback - Type for where/having clause callback functions * @@ -124,6 +155,10 @@ export type WhereCallback = ( refs: RefsForContext, ) => any +export type QueryWhereCallback = ( + refs: QueryRefsForContext, +) => any + /** * SelectValue - Union of all valid values in a select clause * @@ -290,7 +325,7 @@ type NeedsExtraction = T extends * Example: `(refs) => refs.users.createdAt` */ export type OrderByCallback = ( - refs: RefsForContext, + refs: QueryRefsForContext, ) => any /** @@ -388,27 +423,25 @@ export type FunctionalHavingRow = TContext[`schema`] & * After `select()` is called, this type also includes `$selected` which provides access * to the SELECT result fields via `$selected.fieldName` syntax. */ -export type RefsForContext = { - [K in keyof TContext[`schema`]]: IsNonExactOptional< - TContext[`schema`][K] - > extends true - ? IsNonExactNullable extends true - ? // T is both non-exact optional and non-exact nullable (e.g., string | null | undefined) - // Extract the non-undefined and non-null part and place undefined outside - Ref> | undefined - : // T is optional (T | undefined) but not exactly undefined, and not nullable - // Extract the non-undefined part and place undefined outside - Ref> | undefined - : IsNonExactNullable extends true - ? // T is nullable (T | null) but not exactly null, and not optional - // Extract the non-null part and place null outside - Ref> | null - : // T is exactly undefined, exactly null, or neither optional nor nullable - // Wrap in RefProxy as-is (includes exact undefined, exact null, and normal types) - Ref -} & (TContext[`result`] extends object - ? { $selected: Ref } - : {}) +type RefsForSchema = { + [K in keyof TSchema]: IsNonExactOptional extends true + ? IsNonExactNullable extends true + ? Ref> | undefined + : Ref> | undefined + : IsNonExactNullable extends true + ? Ref> | null + : Ref +} & (TResult extends object ? { $selected: Ref } : {}) + +export type RefsForContext = RefsForSchema< + TContext[`schema`], + TContext[`result`] +> + +export type QueryRefsForContext = RefsForSchema< + ResolveQuerySchema, + TContext[`result`] +> /** * Type Detection Helpers @@ -574,6 +607,7 @@ export type MergeContextWithJoinType< TContext extends Context, TNewSchema extends ContextSchema, TJoinType extends `inner` | `left` | `right` | `full` | `outer` | `cross`, + TNewQuerySchema extends ContextSchema = TNewSchema, > = { baseSchema: TContext[`baseSchema`] // Apply optionality immediately to the schema @@ -583,6 +617,12 @@ export type MergeContextWithJoinType< TJoinType, TContext[`fromSourceName`] > + querySchema: ApplyJoinOptionalityToMergedSchema< + ResolveQuerySchema, + TNewQuerySchema, + TJoinType, + TContext[`fromSourceName`] + > fromSourceName: TContext[`fromSourceName`] hasJoins: true // Track join types for reference @@ -685,6 +725,14 @@ export type GetResult = Prettify< TContext[`schema`][TContext[`fromSourceName`]] > +export type GetQueryResult = Prettify< + TContext[`result`] extends object + ? TContext[`result`] + : TContext[`hasJoins`] extends true + ? ResolveQuerySchema + : ResolveQuerySchema[TContext[`fromSourceName`]] +> + /** * ApplyJoinOptionalityToSchema - Legacy helper for complex join scenarios * @@ -807,10 +855,12 @@ export type HasJoinType< export type MergeContextForJoinCallback< TContext extends Context, TNewSchema extends ContextSchema, + TNewQuerySchema extends ContextSchema = TNewSchema, > = { baseSchema: TContext[`baseSchema`] // Merge schemas without applying join optionality - both are non-optional in join condition schema: TContext[`schema`] & TNewSchema + querySchema: ResolveQuerySchema & TNewQuerySchema fromSourceName: TContext[`fromSourceName`] hasJoins: true joinTypes: TContext[`joinTypes`] extends Record diff --git a/packages/db/src/query/index.ts b/packages/db/src/query/index.ts index 524b4dcf1..b7bf4782f 100644 --- a/packages/db/src/query/index.ts +++ b/packages/db/src/query/index.ts @@ -57,8 +57,8 @@ export { liveQueryCollectionOptions, } from './live-query-collection.js' -export { type LiveQueryCollectionConfig } from './live/types.js' -export { type LiveQueryCollectionUtils } from './live/collection-config-builder.js' +export type { LiveQueryCollectionConfig } from './live/types.js' +export type { LiveQueryCollectionUtils } from './live/collection-config-builder.js' // Predicate utilities for predicate push-down export { @@ -72,3 +72,8 @@ export { } from './predicate-utils.js' export { DeduplicatedLoadSubset } from './subset-dedupe.js' + +export { + withQueryableFields, + type QueryableFieldsConfig, +} from './queryable-fields.js' diff --git a/packages/db/src/query/live-query-collection.ts b/packages/db/src/query/live-query-collection.ts index 47eceac15..2db73e83b 100644 --- a/packages/db/src/query/live-query-collection.ts +++ b/packages/db/src/query/live-query-collection.ts @@ -7,7 +7,7 @@ import { 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 { Collection } from '../collection/index.js' +import type { Collection, CollectionImpl } from '../collection/index.js' import type { CollectionConfig, CollectionConfigSingleRowOption, @@ -15,7 +15,14 @@ import type { SingleResult, UtilsRecord, } from '../types.js' -import type { Context, GetResult } from './builder/types.js' +import type { + Context, + GetQueryResult, + GetResult, + InferCollectionType, + SchemaFromSource, + Source, +} from './builder/types.js' type CollectionConfigForContext< TContext extends Context, @@ -35,6 +42,92 @@ type CollectionForContext< ? Collection & SingleResult : Collection & NonSingleResult +type QueryableFieldList = ReadonlyArray + +type KeysFromList = + TKeys extends ReadonlyArray ? TKey : never + +type ResolveQueryableSchema< + TDocument extends object, + TFilterable, + TSortable, +> = [TFilterable] extends [undefined] + ? [TSortable] extends [undefined] + ? TDocument + : Pick< + TDocument, + Extract< + KeysFromList | KeysFromList, + keyof TDocument + > + > + : Pick< + TDocument, + Extract< + KeysFromList | KeysFromList, + keyof TDocument + > + > + +type QuerySchemaFromSourceWithQueryable< + TSource extends Source, + TFilterable extends QueryableFieldList | undefined, + TSortable extends QueryableFieldList | undefined, +> = { + [K in keyof TSource]: TSource[K] extends CollectionImpl< + infer _TOutput, + infer _TKey, + infer _TUtils, + infer _TSchema, + infer _TInput + > + ? ResolveQueryableSchema< + InferCollectionType, + TFilterable, + TSortable + > + : TSource[K] extends QueryBuilder + ? GetQueryResult + : never +} + +type InitialQueryBuilderWithQueryable< + TFilterable extends QueryableFieldList | undefined, + TSortable extends QueryableFieldList | undefined, +> = { + from: ( + source: TSource, + ) => QueryBuilder<{ + baseSchema: SchemaFromSource + schema: SchemaFromSource + querySchema: QuerySchemaFromSourceWithQueryable< + TSource, + TFilterable, + TSortable + > + fromSourceName: keyof TSource & string + hasJoins: false + }> +} + +type LiveQueryCollectionConfigWithQueryable< + TContext extends Context, + TResult extends object, + TFilterable extends QueryableFieldList | undefined, + TSortable extends QueryableFieldList | undefined, +> = Omit< + LiveQueryCollectionConfig, + `query` | `queryable` +> & { + query: ( + q: InitialQueryBuilderWithQueryable, + ) => QueryBuilder + queryable: { + filterable?: TFilterable + sortable?: TSortable + } +} + /** * Creates live query collection options for use with createCollection * @@ -59,6 +152,22 @@ type CollectionForContext< * @param config - Configuration options for the live query collection * @returns Collection options that can be passed to createCollection */ +export function liveQueryCollectionOptions< + TContext extends Context, + TResult extends object = GetResult, + TFilterable extends QueryableFieldList | undefined = undefined, + TSortable extends QueryableFieldList | undefined = undefined, +>( + config: LiveQueryCollectionConfigWithQueryable< + TContext, + TResult, + TFilterable, + TSortable + >, +): CollectionConfigForContext & { + utils: LiveQueryCollectionUtils +} + export function liveQueryCollectionOptions< TContext extends Context, TResult extends object = GetResult, @@ -66,11 +175,31 @@ export function liveQueryCollectionOptions< config: LiveQueryCollectionConfig, ): CollectionConfigForContext & { utils: LiveQueryCollectionUtils +} + +export function liveQueryCollectionOptions< + TContext extends Context, + TResult extends object = GetResult, +>( + config: + | LiveQueryCollectionConfig + | LiveQueryCollectionConfigWithQueryable< + TContext, + TResult, + QueryableFieldList | undefined, + QueryableFieldList | undefined + >, +): CollectionConfigForContext & { + utils: LiveQueryCollectionUtils } { + const normalizedConfig = config as unknown as LiveQueryCollectionConfig< + TContext, + TResult + > const collectionConfigBuilder = new CollectionConfigBuilder< TContext, TResult - >(config) + >(normalizedConfig) return collectionConfigBuilder.getConfig() as CollectionConfigForContext< TContext, TResult @@ -108,6 +237,18 @@ export function liveQueryCollectionOptions< * } * } * }) + * + * // Optional: constrain queryable refs at compile-time + * const constrained = createLiveQueryCollection({ + * queryable: { + * filterable: ['id', 'status'] as const, + * sortable: ['created_at'] as const, + * }, + * query: (q) => + * q + * .from({ post: postsCollection }) + * .where(({ post }) => eq(post.status, 'published')) + * }) * ``` */ @@ -122,6 +263,24 @@ export function createLiveQueryCollection< } // Overload 2: Accept full config object with optional utilities +export function createLiveQueryCollection< + TContext extends Context, + TResult extends object = GetResult, + TUtils extends UtilsRecord = {}, + TFilterable extends QueryableFieldList | undefined = undefined, + TSortable extends QueryableFieldList | undefined = undefined, +>( + config: LiveQueryCollectionConfigWithQueryable< + TContext, + TResult, + TFilterable, + TSortable + > & { utils?: TUtils }, +): CollectionForContext & { + utils: LiveQueryCollectionUtils & TUtils +} + +// Overload 3: Accept full config object with optional utilities export function createLiveQueryCollection< TContext extends Context, TResult extends object = GetResult, @@ -140,6 +299,12 @@ export function createLiveQueryCollection< >( configOrQuery: | (LiveQueryCollectionConfig & { utils?: TUtils }) + | (LiveQueryCollectionConfigWithQueryable< + TContext, + TResult, + QueryableFieldList | undefined, + QueryableFieldList | undefined + > & { utils?: TUtils }) | ((q: InitialQueryBuilder) => QueryBuilder), ): CollectionForContext & { utils: LiveQueryCollectionUtils & TUtils diff --git a/packages/db/src/query/live/types.ts b/packages/db/src/query/live/types.ts index 04d0b389f..fa93fdf48 100644 --- a/packages/db/src/query/live/types.ts +++ b/packages/db/src/query/live/types.ts @@ -69,6 +69,15 @@ export interface LiveQueryCollectionConfig< | ((q: InitialQueryBuilder) => QueryBuilder) | QueryBuilder + /** + * Optional compile-time queryability hints for constrained `where`/`orderBy` refs. + * Runtime behavior is unchanged. + */ + queryable?: { + filterable?: ReadonlyArray + sortable?: ReadonlyArray + } + /** * Function to extract the key from result items * If not provided, defaults to using the key from the D2 stream diff --git a/packages/db/src/query/queryable-fields.ts b/packages/db/src/query/queryable-fields.ts new file mode 100644 index 000000000..889caf1e0 --- /dev/null +++ b/packages/db/src/query/queryable-fields.ts @@ -0,0 +1,81 @@ +import type { CollectionImpl } from '../collection/index.js' +import type { + CollectionWithQuerySchema, + InferCollectionType, +} from './builder/types.js' + +type QueryableField = Extract + +type QueryableFieldList = ReadonlyArray< + QueryableField +> + +type KeysFromList = + TKeys extends ReadonlyArray ? TKey : never + +type ResolveQueryableKeys< + TDocument extends object, + TFilterable, + TSortable, +> = Extract< + KeysFromList | KeysFromList, + QueryableField +> + +type ResolveQueryableSchema< + TDocument extends object, + TFilterable, + TSortable, +> = [TFilterable] extends [undefined] + ? [TSortable] extends [undefined] + ? TDocument + : Pick> + : Pick> + +export type QueryableFieldsConfig< + TDocument extends object, + TFilterable extends QueryableFieldList | undefined = undefined, + TSortable extends QueryableFieldList | undefined = undefined, +> = { + filterable?: TFilterable + sortable?: TSortable +} + +/** + * Adds compile-time queryable field constraints to a collection source. + * + * Runtime behavior is unchanged. This only affects the refs available in + * query callbacks like `where` and `orderBy`. + */ +export function withQueryableFields< + TCollection extends CollectionImpl, + TFilterable extends + | QueryableFieldList> + | undefined = undefined, + TSortable extends + | QueryableFieldList> + | undefined = undefined, +>( + collection: TCollection, + _queryable: QueryableFieldsConfig< + InferCollectionType, + TFilterable, + TSortable + >, +): CollectionWithQuerySchema< + TCollection, + ResolveQueryableSchema< + InferCollectionType, + TFilterable, + TSortable + > +> { + return collection as CollectionWithQuerySchema< + TCollection, + ResolveQueryableSchema< + InferCollectionType, + TFilterable, + TSortable + > + > +} diff --git a/packages/db/tests/query/queryable-fields.test-d.ts b/packages/db/tests/query/queryable-fields.test-d.ts new file mode 100644 index 000000000..0568fb70c --- /dev/null +++ b/packages/db/tests/query/queryable-fields.test-d.ts @@ -0,0 +1,214 @@ +import { describe, expectTypeOf, test } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { + Query, + createLiveQueryCollection, + eq, + withQueryableFields, +} from '../../src/query/index.js' +import { mockSyncCollectionOptions } from '../utils.js' + +type JobEngagementDocument = { + _id: string + status: `ACTIVE` | `INACTIVE` + user_id: string + employer_id: string + created_at: string + updated_at: string + platform_fee_percent: number +} + +function createEngagementsCollection() { + return createCollection( + mockSyncCollectionOptions({ + id: `test-engagements`, + getKey: (engagement) => engagement._id, + initialData: [], + }), + ) +} + +describe(`Queryable field constraints`, () => { + test(`supports queryable option on createLiveQueryCollection config`, () => { + const engagementsCollection = createEngagementsCollection() + + const collection = createLiveQueryCollection({ + queryable: { + filterable: [ + `_id`, + `status`, + `user_id`, + `employer_id`, + `created_at`, + `updated_at`, + ] as const, + sortable: [`created_at`, `updated_at`, `status`] as const, + }, + query: (q) => + q + .from({ engagement: engagementsCollection }) + .where(({ engagement }) => eq(engagement.status, `ACTIVE`)) + .orderBy(({ engagement }) => engagement.updated_at, `desc`), + }) + + expectTypeOf(collection.toArray).toEqualTypeOf< + Array + >() + }) + + test(`queryable option rejects disallowed fields at compile-time`, () => { + const engagementsCollection = createEngagementsCollection() + + createLiveQueryCollection({ + queryable: { + filterable: [ + `_id`, + `status`, + `user_id`, + `employer_id`, + `created_at`, + `updated_at`, + ] as const, + sortable: [`created_at`, `updated_at`, `status`] as const, + }, + query: (q) => + q + .from({ engagement: engagementsCollection }) + .where(({ engagement }) => { + // @ts-expect-error platform_fee_percent is not queryable + return eq(engagement.platform_fee_percent, 0.15) + }), + }) + }) + + test(`empty queryable lists do not widen back to full document`, () => { + const engagementsCollection = createEngagementsCollection() + + createLiveQueryCollection({ + queryable: { + filterable: [] as const, + sortable: [] as const, + }, + query: (q) => + q + .from({ engagement: engagementsCollection }) + .where(({ engagement }) => { + // @ts-expect-error no fields are queryable when both lists are empty + return eq(engagement.status, `ACTIVE`) + }), + }) + }) + + test(`allows configured fields in where and orderBy`, () => { + const engagementsCollection = createEngagementsCollection() + + const queryableEngagements = withQueryableFields(engagementsCollection, { + filterable: [ + `_id`, + `status`, + `user_id`, + `employer_id`, + `created_at`, + `updated_at`, + ] as const, + sortable: [`created_at`, `updated_at`, `status`] as const, + }) + + const collection = createLiveQueryCollection({ + query: (q) => + q + .from({ engagement: queryableEngagements }) + .where(({ engagement }) => eq(engagement.status, `ACTIVE`)) + .orderBy(({ engagement }) => engagement.updated_at, `desc`), + }) + + expectTypeOf(collection.toArray).toEqualTypeOf< + Array + >() + }) + + test(`rejects disallowed where fields at compile-time`, () => { + const engagementsCollection = createEngagementsCollection() + + const queryableEngagements = withQueryableFields(engagementsCollection, { + filterable: [ + `_id`, + `status`, + `user_id`, + `employer_id`, + `created_at`, + `updated_at`, + ] as const, + sortable: [`created_at`, `updated_at`, `status`] as const, + }) + + createLiveQueryCollection({ + query: (q) => + q.from({ engagement: queryableEngagements }).where(({ engagement }) => { + // @ts-expect-error platform_fee_percent is not queryable + return eq(engagement.platform_fee_percent, 0.15) + }), + }) + }) + + test(`rejects disallowed orderBy fields at compile-time`, () => { + const engagementsCollection = createEngagementsCollection() + + const queryableEngagements = withQueryableFields(engagementsCollection, { + filterable: [ + `_id`, + `status`, + `user_id`, + `employer_id`, + `created_at`, + `updated_at`, + ] as const, + sortable: [`created_at`, `updated_at`, `status`] as const, + }) + + createLiveQueryCollection({ + query: (q) => + q + .from({ engagement: queryableEngagements }) + .where(({ engagement }) => eq(engagement.status, `ACTIVE`)) + .orderBy(({ engagement }) => { + // @ts-expect-error platform_fee_percent is not queryable + return engagement.platform_fee_percent + }), + }) + }) + + test(`keeps restrictions across subquery refs while preserving output shape`, () => { + const engagementsCollection = createEngagementsCollection() + + const queryableEngagements = withQueryableFields(engagementsCollection, { + filterable: [ + `_id`, + `status`, + `user_id`, + `employer_id`, + `created_at`, + `updated_at`, + ] as const, + sortable: [`created_at`, `updated_at`, `status`] as const, + }) + + const subquery = new Query().from({ engagement: queryableEngagements }) + + createLiveQueryCollection({ + query: (q) => + q.from({ engagement: subquery }).where(({ engagement }) => { + // @ts-expect-error platform_fee_percent is not queryable via subquery refs + return eq(engagement.platform_fee_percent, 0.15) + }), + }) + + const resultCollection = createLiveQueryCollection({ + query: (q) => q.from({ engagement: subquery }), + }) + + expectTypeOf(resultCollection.toArray).toEqualTypeOf< + Array + >() + }) +})