diff --git a/.changeset/shiny-planes-laugh.md b/.changeset/shiny-planes-laugh.md new file mode 100644 index 000000000..ec90e87ad --- /dev/null +++ b/.changeset/shiny-planes-laugh.md @@ -0,0 +1,6 @@ +--- +'@tanstack/db': patch +--- + +Implement virtual properties end-to-end, including live query behavior and +typing support for virtual metadata on rows. diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index b69689e16..5204b508b 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -29,6 +29,18 @@ const activeUsers = createCollection(liveQueryCollectionOptions({ The result types are automatically inferred from your query structure, providing full TypeScript support. When you use a `select` clause, the result type matches your projection. Without `select`, you get the full schema with proper join optionality. +## Virtual properties + +Live query results include computed, read-only virtual properties on every row: + +- `$synced`: `true` when the row is confirmed by sync; `false` when it is still optimistic. +- `$origin`: `"local"` if the last confirmed change came from this client, otherwise `"remote"`. +- `$key`: the row key for the result. +- `$collectionId`: the source collection ID. + +These props can be used in `where`, `select`, and `orderBy` clauses. They are added to +query outputs automatically and should not be persisted back to storage. + ## Table of Contents - [Creating Live Query Collections](#creating-live-query-collections) diff --git a/packages/db/src/collection/change-events.ts b/packages/db/src/collection/change-events.ts index 1fda5495c..6afac412c 100644 --- a/packages/db/src/collection/change-events.ts +++ b/packages/db/src/collection/change-events.ts @@ -22,6 +22,7 @@ import type { import type { CollectionImpl } from './index.js' import type { SingleRowRefProxy } from '../query/builder/ref-proxy' import type { BasicExpression, OrderBy } from '../query/ir.js' +import type { WithVirtualProps } from '../virtual-props.js' /** * Returns the current state of the collection as an array of changes @@ -58,14 +59,14 @@ export function currentStateAsChanges< T extends object, TKey extends string | number, >( - collection: CollectionLike, + collection: CollectionLike, TKey>, options: CurrentStateAsChangesOptions = {}, -): Array> | void { +): Array, TKey>> | void { // Helper function to collect filtered results const collectFilteredResults = ( - filterFn?: (value: T) => boolean, - ): Array> => { - const result: Array> = [] + filterFn?: (value: WithVirtualProps) => boolean, + ): Array, TKey>> => { + const result: Array, TKey>> = [] for (const [key, value] of collection.entries()) { // If no filter function is provided, include all items if (filterFn?.(value) ?? true) { @@ -106,7 +107,7 @@ export function currentStateAsChanges< } // Convert keys to change messages - const result: Array> = [] + const result: Array, TKey>> = [] for (const key of orderedKeys) { const value = collection.get(key) if (value !== undefined) { @@ -138,7 +139,7 @@ export function currentStateAsChanges< if (optimizationResult.canOptimize) { // Use index optimization - const result: Array> = [] + const result: Array, TKey>> = [] for (const key of optimizationResult.matchingKeys) { const value = collection.get(key) if (value !== undefined) { @@ -241,9 +242,12 @@ export function createFilterFunctionFromExpression( * @param options - The subscription options containing the where clause * @returns A filtered callback function */ -export function createFilteredCallback( +export function createFilteredCallback< + T extends object, + TKey extends string | number = string | number, +>( originalCallback: (changes: Array>) => void, - options: SubscribeChangesOptions, + options: SubscribeChangesOptions, ): (changes: Array>) => void { const filterFn = createFilterFunctionFromExpression(options.whereExpression!) diff --git a/packages/db/src/collection/changes.ts b/packages/db/src/collection/changes.ts index 389d6ba17..dc07cd3f1 100644 --- a/packages/db/src/collection/changes.ts +++ b/packages/db/src/collection/changes.ts @@ -10,6 +10,8 @@ import type { CollectionLifecycleManager } from './lifecycle.js' import type { CollectionSyncManager } from './sync.js' import type { CollectionEventsManager } from './events.js' import type { CollectionImpl } from './index.js' +import type { CollectionStateManager } from './state.js' +import type { WithVirtualProps } from '../virtual-props.js' export class CollectionChangesManager< TOutput extends object = Record, @@ -21,6 +23,7 @@ export class CollectionChangesManager< private sync!: CollectionSyncManager private events!: CollectionEventsManager private collection!: CollectionImpl + private state!: CollectionStateManager public activeSubscribersCount = 0 public changeSubscriptions = new Set() @@ -37,11 +40,13 @@ export class CollectionChangesManager< sync: CollectionSyncManager events: CollectionEventsManager collection: CollectionImpl + state: CollectionStateManager }) { this.lifecycle = deps.lifecycle this.sync = deps.sync this.events = deps.events this.collection = deps.collection + this.state = deps.state } /** @@ -55,6 +60,16 @@ export class CollectionChangesManager< } } + /** + * Enriches a change message with virtual properties ($synced, $origin, $key, $collectionId). + * Uses the "add-if-missing" pattern to preserve virtual properties from upstream collections. + */ + private enrichChangeWithVirtualProps( + change: ChangeMessage, + ): ChangeMessage, TKey> { + return this.state.enrichChangeMessage(change) + } + /** * Emit events either immediately or batch them for later emission */ @@ -70,26 +85,32 @@ export class CollectionChangesManager< } // Either we're not batching, or we're forcing emission (user action or ending batch cycle) - let eventsToEmit = changes + let rawEvents = changes if (forceEmit) { // Force emit is used to end a batch (e.g. after a sync commit). Combine any // buffered optimistic events with the final changes so subscribers see the // whole picture, even if the sync diff is empty. if (this.batchedEvents.length > 0) { - eventsToEmit = [...this.batchedEvents, ...changes] + rawEvents = [...this.batchedEvents, ...changes] } this.batchedEvents = [] this.shouldBatchEvents = false } - if (eventsToEmit.length === 0) { + if (rawEvents.length === 0) { return } + // Enrich all change messages with virtual properties + // This uses the "add-if-missing" pattern to preserve pass-through semantics + const enrichedEvents: Array< + ChangeMessage, TKey> + > = rawEvents.map((change) => this.enrichChangeWithVirtualProps(change)) + // Emit to all listeners for (const subscription of this.changeSubscriptions) { - subscription.emitEvents(eventsToEmit) + subscription.emitEvents(enrichedEvents) } } @@ -97,8 +118,10 @@ export class CollectionChangesManager< * Subscribe to changes in the collection */ public subscribeChanges( - callback: (changes: Array>) => void, - options: SubscribeChangesOptions = {}, + callback: ( + changes: Array>>, + ) => void, + options: SubscribeChangesOptions = {}, ): CollectionSubscription { // Start sync and track subscriber this.addSubscriber() @@ -113,7 +136,7 @@ export class CollectionChangesManager< const { where, ...opts } = options let whereExpression = opts.whereExpression if (where) { - const proxy = createSingleRowRefProxy() + const proxy = createSingleRowRefProxy>() const result = where(proxy) whereExpression = toExpression(result) } diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 39f59ed73..47c8d863a 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -37,6 +37,7 @@ import type { SingleRowRefProxy } from '../query/builder/ref-proxy' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { BTreeIndex } from '../indexes/btree-index.js' import type { IndexProxy } from '../indexes/lazy-index.js' +import type { WithVirtualProps } from '../virtual-props.js' /** * Enhanced Collection interface that includes both data type T and utilities TUtils @@ -340,6 +341,7 @@ export class CollectionImpl< lifecycle: this._lifecycle, sync: this._sync, events: this._events, + state: this._state, // Required for enriching changes with virtual properties }) this._events.setDeps({ collection: this, // Required for adding to emitted events @@ -451,8 +453,8 @@ export class CollectionImpl< /** * Get the current value for a key (virtual derived state) */ - public get(key: TKey): TOutput | undefined { - return this._state.get(key) + public get(key: TKey): WithVirtualProps | undefined { + return this._state.getWithVirtualProps(key) } /** @@ -479,40 +481,68 @@ export class CollectionImpl< /** * Get all values (virtual derived state) */ - public *values(): IterableIterator { - yield* this._state.values() + public *values(): IterableIterator> { + for (const key of this._state.keys()) { + const value = this.get(key) + if (value !== undefined) { + yield value + } + } } /** * Get all entries (virtual derived state) */ - public *entries(): IterableIterator<[TKey, TOutput]> { - yield* this._state.entries() + public *entries(): IterableIterator<[TKey, WithVirtualProps]> { + for (const key of this._state.keys()) { + const value = this.get(key) + if (value !== undefined) { + yield [key, value] + } + } } /** * Get all entries (virtual derived state) */ - public *[Symbol.iterator](): IterableIterator<[TKey, TOutput]> { - yield* this._state[Symbol.iterator]() + public *[Symbol.iterator](): IterableIterator< + [TKey, WithVirtualProps] + > { + yield* this.entries() } /** * Execute a callback for each entry in the collection */ public forEach( - callbackfn: (value: TOutput, key: TKey, index: number) => void, + callbackfn: ( + value: WithVirtualProps, + key: TKey, + index: number, + ) => void, ): void { - return this._state.forEach(callbackfn) + let index = 0 + for (const [key, value] of this.entries()) { + callbackfn(value, key, index++) + } } /** * Create a new array with the results of calling a function for each entry in the collection */ public map( - callbackfn: (value: TOutput, key: TKey, index: number) => U, + callbackfn: ( + value: WithVirtualProps, + key: TKey, + index: number, + ) => U, ): Array { - return this._state.map(callbackfn) + const result: Array = [] + let index = 0 + for (const [key, value] of this.entries()) { + result.push(callbackfn(value, key, index++)) + } + return result } public getKeyFromItem(item: TOutput): TKey { @@ -755,7 +785,7 @@ export class CollectionImpl< * } */ get state() { - const result = new Map() + const result = new Map>() for (const [key, value] of this.entries()) { result.set(key, value) } @@ -768,7 +798,7 @@ export class CollectionImpl< * * @returns Promise that resolves to a Map containing all items in the collection */ - stateWhenReady(): Promise> { + stateWhenReady(): Promise>> { // If we already have data or collection is ready, resolve immediately if (this.size > 0 || this.isReady()) { return Promise.resolve(this.state) @@ -793,7 +823,7 @@ export class CollectionImpl< * * @returns Promise that resolves to an Array containing all items in the collection */ - toArrayWhenReady(): Promise> { + toArrayWhenReady(): Promise>> { // If we already have data or collection is ready, resolve immediately if (this.size > 0 || this.isReady()) { return Promise.resolve(this.toArray) @@ -823,7 +853,7 @@ export class CollectionImpl< */ public currentStateAsChanges( options: CurrentStateAsChangesOptions = {}, - ): Array> | void { + ): Array>> | void { return currentStateAsChanges(this, options) } @@ -870,8 +900,10 @@ export class CollectionImpl< * }) */ public subscribeChanges( - callback: (changes: Array>) => void, - options: SubscribeChangesOptions = {}, + callback: ( + changes: Array>>, + ) => void, + options: SubscribeChangesOptions = {}, ): CollectionSubscription { return this._changes.subscribeChanges(callback, options) } diff --git a/packages/db/src/collection/mutations.ts b/packages/db/src/collection/mutations.ts index 48e8b7b20..963110801 100644 --- a/packages/db/src/collection/mutations.ts +++ b/packages/db/src/collection/mutations.ts @@ -17,6 +17,7 @@ import { UndefinedKeyError, UpdateKeyNotFoundError, } from '../errors' +import { DIRECT_TRANSACTION_METADATA_KEY } from './transaction-metadata.js' import type { Collection, CollectionImpl } from './index.js' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { @@ -153,6 +154,14 @@ export class CollectionMutationsManager< return `KEY::${this.id}/${key}` } + private markPendingLocalOrigins( + mutations: Array>, + ): void { + for (const mutation of mutations) { + this.state.pendingLocalOrigins.add(mutation.key as TKey) + } + } + /** * Inserts one or more items into the collection */ @@ -222,6 +231,9 @@ export class CollectionMutationsManager< } else { // Create a new transaction with a mutation function that calls the onInsert handler const directOpTransaction = createTransaction({ + metadata: { + [DIRECT_TRANSACTION_METADATA_KEY]: true, + }, mutationFn: async (params) => { // Call the onInsert handler with the transaction and collection return await this.config.onInsert!({ @@ -237,6 +249,7 @@ export class CollectionMutationsManager< // Apply mutations to the new transaction directOpTransaction.applyMutations(mutations) + this.markPendingLocalOrigins(mutations) // Errors still reject tx.isPersisted.promise; this catch only prevents global unhandled rejections directOpTransaction.commit().catch(() => undefined) @@ -417,6 +430,9 @@ export class CollectionMutationsManager< // Create a new transaction with a mutation function that calls the onUpdate handler const directOpTransaction = createTransaction({ + metadata: { + [DIRECT_TRANSACTION_METADATA_KEY]: true, + }, mutationFn: async (params) => { // Call the onUpdate handler with the transaction and collection return this.config.onUpdate!({ @@ -432,6 +448,7 @@ export class CollectionMutationsManager< // Apply mutations to the new transaction directOpTransaction.applyMutations(mutations) + this.markPendingLocalOrigins(mutations) // Errors still hit tx.isPersisted.promise; avoid leaking an unhandled rejection from the fire-and-forget commit directOpTransaction.commit().catch(() => undefined) @@ -519,6 +536,9 @@ export class CollectionMutationsManager< // Create a new transaction with a mutation function that calls the onDelete handler const directOpTransaction = createTransaction({ autoCommit: true, + metadata: { + [DIRECT_TRANSACTION_METADATA_KEY]: true, + }, mutationFn: async (params) => { // Call the onDelete handler with the transaction and collection return this.config.onDelete!({ @@ -534,6 +554,7 @@ export class CollectionMutationsManager< // Apply mutations to the new transaction directOpTransaction.applyMutations(mutations) + this.markPendingLocalOrigins(mutations) // Errors still reject tx.isPersisted.promise; silence the internal commit promise to prevent test noise directOpTransaction.commit().catch(() => undefined) diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index b873610f6..f31cddd16 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -1,5 +1,8 @@ import { deepEquals } from '../utils' import { SortedMap } from '../SortedMap' +import { enrichRowWithVirtualProps } from '../virtual-props.js' +import { DIRECT_TRANSACTION_METADATA_KEY } from './transaction-metadata.js' +import type { VirtualOrigin, WithVirtualProps } from '../virtual-props.js' import type { Transaction } from '../transactions' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { @@ -57,6 +60,40 @@ export class CollectionStateManager< // Optimistic state tracking - make public for testing public optimisticUpserts = new Map() public optimisticDeletes = new Set() + public pendingOptimisticUpserts = new Map() + public pendingOptimisticDeletes = new Set() + public pendingOptimisticDirectUpserts = new Set() + public pendingOptimisticDirectDeletes = new Set() + + /** + * Tracks the origin of confirmed changes for each row. + * 'local' = change originated from this client + * 'remote' = change was received via sync + * + * This is used for the $origin virtual property. + * Note: This only tracks *confirmed* changes, not optimistic ones. + * Optimistic changes are always considered 'local' for $origin. + */ + public rowOrigins = new Map() + + /** + * Tracks keys that have pending local changes. + * Used to determine whether sync-confirmed data should have 'local' or 'remote' origin. + * When sync confirms data for a key with pending local changes, it keeps 'local' origin. + */ + public pendingLocalChanges = new Set() + public pendingLocalOrigins = new Set() + + private virtualPropsCache = new WeakMap< + object, + { + synced: boolean + origin: VirtualOrigin + key: TKey + collectionId: string + enriched: WithVirtualProps + } + >() // Cached size for performance public size = 0 @@ -67,6 +104,7 @@ export class CollectionStateManager< public recentlySyncedKeys = new Set() public hasReceivedFirstCommit = false public isCommittingSyncTransactions = false + public isLocalOnly = false /** * Creates a new CollectionState manager @@ -96,6 +134,112 @@ export class CollectionStateManager< this._events = deps.events } + /** + * Checks if a row has pending optimistic mutations (not yet confirmed by sync). + * Used to compute the $synced virtual property. + */ + public isRowSynced(key: TKey): boolean { + if (this.isLocalOnly) { + return true + } + return !this.optimisticUpserts.has(key) && !this.optimisticDeletes.has(key) + } + + /** + * Gets the origin of the last confirmed change to a row. + * Returns 'local' if the row has optimistic mutations (optimistic changes are local). + * Used to compute the $origin virtual property. + */ + public getRowOrigin(key: TKey): VirtualOrigin { + if (this.isLocalOnly) { + return 'local' + } + // If there are optimistic changes, they're local + if (this.optimisticUpserts.has(key) || this.optimisticDeletes.has(key)) { + return 'local' + } + // Otherwise, return the confirmed origin (defaults to 'remote' for synced data) + return this.rowOrigins.get(key) ?? 'remote' + } + + /** + * Enriches a row with virtual properties using the "add-if-missing" pattern. + * If the row already has virtual properties (from an upstream collection), + * they are preserved. Otherwise, new values are computed. + */ + public enrichWithVirtualProps( + row: TOutput, + key: TKey, + ): WithVirtualProps { + const existingRow = row as Partial> + const synced = existingRow.$synced ?? this.isRowSynced(key) + const origin = existingRow.$origin ?? this.getRowOrigin(key) + const resolvedKey = existingRow.$key ?? key + const collectionId = existingRow.$collectionId ?? this.collection.id + + const cached = this.virtualPropsCache.get(row as object) + if ( + cached && + cached.synced === synced && + cached.origin === origin && + cached.key === resolvedKey && + cached.collectionId === collectionId + ) { + return cached.enriched + } + + const enriched = { + ...row, + $synced: synced, + $origin: origin, + $key: resolvedKey, + $collectionId: collectionId, + } as WithVirtualProps + + this.virtualPropsCache.set(row as object, { + synced, + origin, + key: resolvedKey, + collectionId, + enriched, + }) + + return enriched + } + + /** + * Creates a change message with virtual properties. + * Uses the "add-if-missing" pattern so that pass-through from upstream + * collections works correctly. + */ + public enrichChangeMessage( + change: ChangeMessage, + ): ChangeMessage, TKey> { + const enrichedValue = this.enrichWithVirtualProps(change.value, change.key) + const enrichedPreviousValue = change.previousValue + ? this.enrichWithVirtualProps(change.previousValue, change.key) + : undefined + + return { + ...change, + value: enrichedValue, + previousValue: enrichedPreviousValue, + } as ChangeMessage, TKey> + } + + /** + * Get the current value for a key enriched with virtual properties. + */ + public getWithVirtualProps( + key: TKey, + ): WithVirtualProps | undefined { + const value = this.get(key) + if (value === undefined) { + return undefined + } + return this.enrichWithVirtualProps(value, key) + } + /** * Get the current value for a key (virtual derived state) */ @@ -243,9 +387,106 @@ export class CollectionStateManager< const previousState = new Map(this.optimisticUpserts) const previousDeletes = new Set(this.optimisticDeletes) + // Update pending optimistic state for completed/failed transactions + for (const transaction of this.transactions.values()) { + const isDirectTransaction = + transaction.metadata[DIRECT_TRANSACTION_METADATA_KEY] === true + if (transaction.state === `completed`) { + for (const mutation of transaction.mutations) { + if (!this.isThisCollection(mutation.collection)) { + continue + } + this.pendingLocalOrigins.add(mutation.key) + if (!mutation.optimistic) { + continue + } + switch (mutation.type) { + case `insert`: + case `update`: + this.pendingOptimisticUpserts.set( + mutation.key, + mutation.modified as TOutput, + ) + this.pendingOptimisticDeletes.delete(mutation.key) + if (isDirectTransaction) { + this.pendingOptimisticDirectUpserts.add(mutation.key) + this.pendingOptimisticDirectDeletes.delete(mutation.key) + } else { + this.pendingOptimisticDirectUpserts.delete(mutation.key) + this.pendingOptimisticDirectDeletes.delete(mutation.key) + } + break + case `delete`: + this.pendingOptimisticUpserts.delete(mutation.key) + this.pendingOptimisticDeletes.add(mutation.key) + if (isDirectTransaction) { + this.pendingOptimisticDirectUpserts.delete(mutation.key) + this.pendingOptimisticDirectDeletes.add(mutation.key) + } else { + this.pendingOptimisticDirectUpserts.delete(mutation.key) + this.pendingOptimisticDirectDeletes.delete(mutation.key) + } + break + } + } + } else if (transaction.state === `failed`) { + for (const mutation of transaction.mutations) { + if (!this.isThisCollection(mutation.collection)) { + continue + } + this.pendingLocalOrigins.delete(mutation.key) + if (mutation.optimistic) { + this.pendingOptimisticUpserts.delete(mutation.key) + this.pendingOptimisticDeletes.delete(mutation.key) + this.pendingOptimisticDirectUpserts.delete(mutation.key) + this.pendingOptimisticDirectDeletes.delete(mutation.key) + } + } + } + } + // Clear current optimistic state this.optimisticUpserts.clear() this.optimisticDeletes.clear() + this.pendingLocalChanges.clear() + + // Seed optimistic state with pending optimistic mutations only when a sync is pending + const pendingSyncKeys = new Set() + for (const transaction of this.pendingSyncedTransactions) { + for (const operation of transaction.operations) { + pendingSyncKeys.add(operation.key as TKey) + } + } + const staleOptimisticUpserts: Array = [] + for (const [key, value] of this.pendingOptimisticUpserts) { + if ( + pendingSyncKeys.has(key) || + this.pendingOptimisticDirectUpserts.has(key) + ) { + this.optimisticUpserts.set(key, value) + } else { + staleOptimisticUpserts.push(key) + } + } + for (const key of staleOptimisticUpserts) { + this.pendingOptimisticUpserts.delete(key) + this.pendingLocalOrigins.delete(key) + } + const staleOptimisticDeletes: Array = [] + for (const key of this.pendingOptimisticDeletes) { + if ( + pendingSyncKeys.has(key) || + this.pendingOptimisticDirectDeletes.has(key) + ) { + this.optimisticDeletes.add(key) + } else { + staleOptimisticDeletes.push(key) + } + } + for (const key of staleOptimisticDeletes) { + this.pendingOptimisticDeletes.delete(key) + this.pendingLocalOrigins.delete(key) + } const activeTransactions: Array> = [] @@ -258,7 +499,14 @@ export class CollectionStateManager< // Apply active transactions only (completed transactions are handled by sync operations) for (const transaction of activeTransactions) { for (const mutation of transaction.mutations) { - if (this.isThisCollection(mutation.collection) && mutation.optimistic) { + if (!this.isThisCollection(mutation.collection)) { + continue + } + + // Track that this key has pending local changes for $origin tracking + this.pendingLocalChanges.add(mutation.key) + + if (mutation.optimistic) { switch (mutation.type) { case `insert`: case `update`: @@ -305,12 +553,12 @@ export class CollectionStateManager< // that will immediately restore the same data, but only for completed transactions // IMPORTANT: Skip complex filtering for user-triggered actions to prevent UI blocking if (this.pendingSyncedTransactions.length > 0 && !triggeredByUserAction) { - const pendingSyncKeys = new Set() + const pendingSyncKeysForFilter = new Set() // Collect keys from pending sync operations for (const transaction of this.pendingSyncedTransactions) { for (const operation of transaction.operations) { - pendingSyncKeys.add(operation.key as TKey) + pendingSyncKeysForFilter.add(operation.key as TKey) } } @@ -318,7 +566,10 @@ export class CollectionStateManager< // 1. Have pending sync operations AND // 2. Are from completed transactions (being cleaned up) const filteredEvents = filteredEventsBySyncStatus.filter((event) => { - if (event.type === `delete` && pendingSyncKeys.has(event.key)) { + if ( + event.type === `delete` && + pendingSyncKeysForFilter.has(event.key) + ) { // Check if this delete is from clearing optimistic state of completed transactions // We can infer this by checking if we have no remaining optimistic mutations for this key const hasActiveOptimisticMutation = activeTransactions.some((tx) => @@ -485,6 +736,10 @@ export class CollectionStateManager< // Set flag to prevent redundant optimistic state recalculations this.isCommittingSyncTransactions = true + const previousRowOrigins = new Map(this.rowOrigins) + const previousOptimisticUpserts = new Map(this.optimisticUpserts) + const previousOptimisticDeletes = new Set(this.optimisticDeletes) + // Get the optimistic snapshot from the truncate transaction (captured when truncate() was called) const truncateOptimisticSnapshot = hasTruncateSync ? committedSyncedTransactions.find((t) => t.truncate) @@ -515,6 +770,47 @@ export class CollectionStateManager< const events: Array> = [] const rowUpdateMode = this.config.sync.rowUpdateMode || `partial` + const completedOptimisticOps = new Map< + TKey, + { type: string; value: TOutput } + >() + + for (const transaction of this.transactions.values()) { + if (transaction.state === `completed`) { + for (const mutation of transaction.mutations) { + if (this.isThisCollection(mutation.collection)) { + if (mutation.optimistic) { + completedOptimisticOps.set(mutation.key, { + type: mutation.type, + value: mutation.modified as TOutput, + }) + } + } + } + } + } + + const getPreviousVirtualProps = (key: TKey) => { + if (this.isLocalOnly) { + return { synced: true, origin: 'local' as const } + } + if ( + previousOptimisticUpserts.has(key) || + previousOptimisticDeletes.has(key) || + completedOptimisticOps.has(key) + ) { + return { synced: false, origin: 'local' as const } + } + return { + synced: true, + origin: previousRowOrigins.get(key) ?? 'remote', + } + } + + const getNextVirtualProps = (key: TKey) => ({ + synced: this.isRowSynced(key), + origin: this.getRowOrigin(key), + }) for (const transaction of committedSyncedTransactions) { // Handle truncate operations first @@ -582,10 +878,26 @@ export class CollectionStateManager< break } + // Determine origin: 'local' for local-only collections or pending local changes + const origin: VirtualOrigin = + this.isLocalOnly || + this.pendingLocalChanges.has(key) || + this.pendingLocalOrigins.has(key) + ? 'local' + : 'remote' + // Update synced data switch (operation.type) { case `insert`: this.syncedData.set(key, operation.value) + this.rowOrigins.set(key, origin) + // Clear pending local changes now that sync has confirmed + this.pendingLocalChanges.delete(key) + this.pendingLocalOrigins.delete(key) + this.pendingOptimisticUpserts.delete(key) + this.pendingOptimisticDeletes.delete(key) + this.pendingOptimisticDirectUpserts.delete(key) + this.pendingOptimisticDirectDeletes.delete(key) break case `update`: { if (rowUpdateMode === `partial`) { @@ -598,10 +910,26 @@ export class CollectionStateManager< } else { this.syncedData.set(key, operation.value) } + this.rowOrigins.set(key, origin) + // Clear pending local changes now that sync has confirmed + this.pendingLocalChanges.delete(key) + this.pendingLocalOrigins.delete(key) + this.pendingOptimisticUpserts.delete(key) + this.pendingOptimisticDeletes.delete(key) + this.pendingOptimisticDirectUpserts.delete(key) + this.pendingOptimisticDirectDeletes.delete(key) break } case `delete`: this.syncedData.delete(key) + // Clean up origin and pending tracking for deleted rows + this.rowOrigins.delete(key) + this.pendingLocalChanges.delete(key) + this.pendingLocalOrigins.delete(key) + this.pendingOptimisticUpserts.delete(key) + this.pendingOptimisticDeletes.delete(key) + this.pendingOptimisticDirectUpserts.delete(key) + this.pendingOptimisticDirectDeletes.delete(key) break } } @@ -721,30 +1049,25 @@ export class CollectionStateManager< } } - // Check for redundant sync operations that match completed optimistic operations - const completedOptimisticOps = new Map() - - for (const transaction of this.transactions.values()) { - if (transaction.state === `completed`) { - for (const mutation of transaction.mutations) { - if ( - mutation.optimistic && - this.isThisCollection(mutation.collection) && - changedKeys.has(mutation.key) - ) { - completedOptimisticOps.set(mutation.key, { - type: mutation.type, - value: mutation.modified, - }) - } - } - } - } - // Now check what actually changed in the final visible state for (const key of changedKeys) { const previousVisibleValue = currentVisibleState.get(key) const newVisibleValue = this.get(key) // This returns the new derived state + const previousVirtualProps = getPreviousVirtualProps(key) + const nextVirtualProps = getNextVirtualProps(key) + const virtualChanged = + previousVirtualProps.synced !== nextVirtualProps.synced || + previousVirtualProps.origin !== nextVirtualProps.origin + const previousValueWithVirtual = + previousVisibleValue !== undefined + ? enrichRowWithVirtualProps( + previousVisibleValue, + key, + this.collection.id, + () => previousVirtualProps.synced, + () => previousVirtualProps.origin, + ) + : undefined // Check if this sync operation is redundant with a completed optimistic operation const completedOp = completedOptimisticOps.get(key) @@ -766,37 +1089,65 @@ export class CollectionStateManager< } } - if (!isRedundantSync) { - if ( - previousVisibleValue === undefined && - newVisibleValue !== undefined - ) { + const shouldEmitVirtualUpdate = + virtualChanged && + previousVisibleValue !== undefined && + newVisibleValue !== undefined && + deepEquals(previousVisibleValue, newVisibleValue) + + if (isRedundantSync && !shouldEmitVirtualUpdate) { + continue + } + + if ( + previousVisibleValue === undefined && + newVisibleValue !== undefined + ) { + const completedOptimisticOp = completedOptimisticOps.get(key) + if (completedOptimisticOp) { + const previousValueFromCompleted = completedOptimisticOp.value + const previousValueWithVirtualFromCompleted = + enrichRowWithVirtualProps( + previousValueFromCompleted, + key, + this.collection.id, + () => previousVirtualProps.synced, + () => previousVirtualProps.origin, + ) events.push({ - type: `insert`, + type: `update`, key, value: newVisibleValue, + previousValue: previousValueWithVirtualFromCompleted, }) - } else if ( - previousVisibleValue !== undefined && - newVisibleValue === undefined - ) { - events.push({ - type: `delete`, - key, - value: previousVisibleValue, - }) - } else if ( - previousVisibleValue !== undefined && - newVisibleValue !== undefined && - !deepEquals(previousVisibleValue, newVisibleValue) - ) { + } else { events.push({ - type: `update`, + type: `insert`, key, value: newVisibleValue, - previousValue: previousVisibleValue, }) } + } else if ( + previousVisibleValue !== undefined && + newVisibleValue === undefined + ) { + events.push({ + type: `delete`, + key, + value: previousValueWithVirtual ?? previousVisibleValue, + }) + } else if ( + previousVisibleValue !== undefined && + newVisibleValue !== undefined && + (!deepEquals(previousVisibleValue, newVisibleValue) || + shouldEmitVirtualUpdate) + ) { + events.push({ + type: `update`, + key, + value: newVisibleValue, + previousValue: previousValueWithVirtual ?? previousVisibleValue, + }) } } @@ -908,6 +1259,14 @@ export class CollectionStateManager< this.syncedMetadata.clear() this.optimisticUpserts.clear() this.optimisticDeletes.clear() + this.pendingOptimisticUpserts.clear() + this.pendingOptimisticDeletes.clear() + this.pendingOptimisticDirectUpserts.clear() + this.pendingOptimisticDirectDeletes.clear() + this.rowOrigins.clear() + this.pendingLocalChanges.clear() + this.pendingLocalOrigins.clear() + this.isLocalOnly = false this.size = 0 this.pendingSyncedTransactions = [] this.syncedKeys.clear() diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index 4b71e4afd..1f50cc889 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -120,6 +120,10 @@ export class CollectionSyncManager< key = this.config.getKey(messageWithOptionalKey.value) } + if (this.state.pendingLocalChanges.has(key)) { + this.state.pendingLocalOrigins.add(key) + } + let messageType = messageWithOptionalKey.type // Check if an item with this key already exists when inserting diff --git a/packages/db/src/collection/transaction-metadata.ts b/packages/db/src/collection/transaction-metadata.ts new file mode 100644 index 000000000..c1de2abb9 --- /dev/null +++ b/packages/db/src/collection/transaction-metadata.ts @@ -0,0 +1 @@ +export const DIRECT_TRANSACTION_METADATA_KEY = `__tanstack_db_direct` diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index ccf7cbb6e..838bde884 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -17,6 +17,15 @@ export { deepEquals } from './utils' export * from './paced-mutations' export * from './strategies/index.js' +// Virtual properties exports +export { + type VirtualRowProps, + type VirtualOrigin, + type WithVirtualProps, + type WithoutVirtualProps, + hasVirtualProps, +} from './virtual-props.js' + // Index system exports export * from './indexes/base-index.js' export * from './indexes/btree-index.js' diff --git a/packages/db/src/indexes/auto-index.ts b/packages/db/src/indexes/auto-index.ts index 098c92b50..d58c626e5 100644 --- a/packages/db/src/indexes/auto-index.ts +++ b/packages/db/src/indexes/auto-index.ts @@ -1,4 +1,5 @@ import { DEFAULT_COMPARE_OPTIONS } from '../utils' +import { hasVirtualPropPath } from '../virtual-props' import { BTreeIndex } from './btree-index' import type { CompareOptions } from '../query/builder/types' import type { BasicExpression } from '../query/ir' @@ -27,6 +28,9 @@ export function ensureIndexForField< compareOptions?: CompareOptions, compareFn?: (a: any, b: any) => number, ) { + if (hasVirtualPropPath(fieldPath)) { + return + } if (!shouldAutoIndex(collection)) { return } diff --git a/packages/db/src/local-only.ts b/packages/db/src/local-only.ts index 68168ae02..0f14ea92b 100644 --- a/packages/db/src/local-only.ts +++ b/packages/db/src/local-only.ts @@ -312,9 +312,16 @@ function createLocalOnlySync( syncWrite = write syncCommit = commit collection = params.collection + params.collection._state.isLocalOnly = true // Apply initial data if provided if (initialData && initialData.length > 0) { + // Mark initial data as local so $origin is 'local' for local-only collections + for (const item of initialData) { + const key = params.collection.getKeyFromItem(item) + params.collection._state.pendingLocalChanges.add(key) + } + begin() initialData.forEach((item) => { write({ diff --git a/packages/db/src/query/builder/ref-proxy.ts b/packages/db/src/query/builder/ref-proxy.ts index b3f79ef51..10575a12d 100644 --- a/packages/db/src/query/builder/ref-proxy.ts +++ b/packages/db/src/query/builder/ref-proxy.ts @@ -11,18 +11,38 @@ export interface RefProxy { readonly __type: T } +/** + * Virtual properties available on all row ref proxies. + * These allow querying on sync status, origin, key, and collection ID. + */ +export type VirtualPropsRefProxy< + TKey extends string | number = string | number, +> = { + readonly $synced: RefLeaf + readonly $origin: RefLeaf<'local' | 'remote'> + readonly $key: RefLeaf + readonly $collectionId: RefLeaf +} + /** * Type for creating a RefProxy for a single row/type without namespacing * Used in collection indexes and where clauses + * + * Includes virtual properties ($synced, $origin, $key, $collectionId) for + * querying on sync status and row metadata. */ -export type SingleRowRefProxy = +export type SingleRowRefProxy< + T, + TKey extends string | number = string | number, +> = T extends Record ? { [K in keyof T]: T[K] extends Record - ? SingleRowRefProxy & RefProxy + ? SingleRowRefProxy & RefProxy : RefLeaf - } & RefProxy - : RefProxy + } & RefProxy & + VirtualPropsRefProxy + : RefProxy & VirtualPropsRefProxy /** * Creates a proxy object that records property access paths for a single row diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 11360dd82..36c240142 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -9,6 +9,7 @@ import type { Value, } from '../ir.js' import type { QueryBuilder } from './index.js' +import type { VirtualOrigin, WithVirtualProps } from '../../virtual-props.js' /** * Context - The central state container for query builder operations @@ -83,7 +84,9 @@ export type Source = { * This can be an explicit type passed by the user or the schema output type. */ export type InferCollectionType = - T extends CollectionImpl ? TOutput : never + T extends CollectionImpl + ? WithVirtualProps + : never /** * SchemaFromSource - Converts a Source definition into a ContextSchema @@ -471,6 +474,32 @@ type NonUndefined = T extends undefined ? never : T // Helper type to extract non-null type type NonNull = T extends null ? never : T +/** + * Virtual properties available on all Ref types in query builders. + * These allow querying on sync status, origin, key, and collection ID. + * + * @example + * ```typescript + * // Filter by sync status + * .where(({ user }) => eq(user.$synced, true)) + * + * // Filter by origin + * .where(({ order }) => eq(order.$origin, 'local')) + * + * // Access key in select + * .select(({ user }) => ({ + * key: user.$key, + * collectionId: user.$collectionId, + * })) + * ``` + */ +type VirtualPropsRef = { + readonly $synced: RefLeaf + readonly $origin: RefLeaf + readonly $key: RefLeaf + readonly $collectionId: RefLeaf +} + /** * Ref - The user-facing ref interface for the query builder * @@ -482,12 +511,16 @@ type NonNull = T extends null ? never : T * When spread in select clauses, it correctly produces the underlying data type * without Ref wrappers, enabling clean spread operations. * + * Includes virtual properties ($synced, $origin, $key, $collectionId) for + * querying on sync status and row metadata. + * * Example usage: * ```typescript * // Clean interface - no internal properties visible * const users: Ref<{ id: number; profile?: { bio: string } }> = { ... } * users.id // Ref - clean display * users.profile?.bio // Ref - nested optional access works + * users.$synced // RefLeaf - virtual property access * * // Spread operations work cleanly: * select(({ user }) => ({ ...user })) // Returns User type, not Ref types @@ -513,7 +546,8 @@ export type Ref = { IsPlainObject extends true ? Ref : RefLeaf -} & RefLeaf +} & RefLeaf & + VirtualPropsRef /** * Ref - The user-facing ref type with clean IDE display @@ -650,6 +684,10 @@ export type InferResultType = ? GetResult | undefined : Array> +type WithVirtualPropsIfObject = TResult extends object + ? WithVirtualProps + : TResult + /** * GetResult - Determines the final result type of a query * @@ -677,7 +715,7 @@ export type InferResultType = */ export type GetResult = Prettify< TContext[`result`] extends object - ? TContext[`result`] + ? WithVirtualPropsIfObject : TContext[`hasJoins`] extends true ? // Optionality is already applied in the schema, just return it TContext[`schema`] diff --git a/packages/db/src/query/compiler/group-by.ts b/packages/db/src/query/compiler/group-by.ts index a20780696..33cd0a76c 100644 --- a/packages/db/src/query/compiler/group-by.ts +++ b/packages/db/src/query/compiler/group-by.ts @@ -21,6 +21,43 @@ import type { Select, } from '../ir.js' import type { NamespacedAndKeyedStream, NamespacedRow } from '../../types.js' +import type { VirtualOrigin } from '../../virtual-props.js' + +const VIRTUAL_SYNCED_KEY = `__virtual_synced__` +const VIRTUAL_HAS_LOCAL_KEY = `__virtual_has_local__` + +type RowVirtualMetadata = { + synced: boolean + hasLocal: boolean +} + +function getRowVirtualMetadata(row: NamespacedRow): RowVirtualMetadata { + let found = false + let allSynced = true + let hasLocal = false + + for (const [alias, value] of Object.entries(row)) { + if (alias === `$selected`) continue + const asRecord = value + const hasSyncedProp = `$synced` in asRecord + const hasOriginProp = `$origin` in asRecord + if (!hasSyncedProp && !hasOriginProp) { + continue + } + found = true + if (asRecord.$synced === false) { + allSynced = false + } + if (asRecord.$origin === `local`) { + hasLocal = true + } + } + + return { + synced: found ? allSynced : true, + hasLocal, + } +} const { sum, count, avg, min, max } = groupByOperators @@ -80,11 +117,39 @@ export function processGroupBy( havingClauses?: Array, selectClause?: Select, fnHavingClauses?: Array<(row: any) => any>, + aggregateCollectionId?: string, ): NamespacedAndKeyedStream { + const virtualAggregates: Record = { + [VIRTUAL_SYNCED_KEY]: { + preMap: ([, row]: [string, NamespacedRow]) => + getRowVirtualMetadata(row).synced, + reduce: (values: Array<[boolean, number]>) => { + for (const [isSynced, multiplicity] of values) { + if (!isSynced && multiplicity > 0) { + return false + } + } + return true + }, + }, + [VIRTUAL_HAS_LOCAL_KEY]: { + preMap: ([, row]: [string, NamespacedRow]) => + getRowVirtualMetadata(row).hasLocal, + reduce: (values: Array<[boolean, number]>) => { + for (const [isLocal, multiplicity] of values) { + if (isLocal && multiplicity > 0) { + return true + } + } + return false + }, + }, + } + // Handle empty GROUP BY (single-group aggregation) if (groupByClause.length === 0) { // For single-group aggregation, create a single group with all data - const aggregates: Record = {} + const aggregates: Record = { ...virtualAggregates } if (selectClause) { // Scan the SELECT clause for aggregate functions @@ -122,11 +187,23 @@ export function processGroupBy( } // Use a single key for the result and update $selected + const { + [VIRTUAL_SYNCED_KEY]: groupSynced, + [VIRTUAL_HAS_LOCAL_KEY]: groupHasLocal, + ...rest + } = aggregatedRow as Record + + const origin: VirtualOrigin = groupHasLocal ? `local` : `remote` + return [ `single_group`, { - ...aggregatedRow, + ...rest, $selected: finalResults, + $synced: groupSynced ?? true, + $origin: origin, + $key: `single_group`, + $collectionId: aggregateCollectionId ?? rest.$collectionId, }, ] as [unknown, Record] }), @@ -200,7 +277,7 @@ export function processGroupBy( } // Create aggregate functions for any aggregated columns in the SELECT clause - const aggregates: Record = {} + const aggregates: Record = { ...virtualAggregates } if (selectClause) { // Scan the SELECT clause for aggregate functions @@ -258,11 +335,23 @@ export function processGroupBy( finalKey = serializeValue(keyParts) } + const { + [VIRTUAL_SYNCED_KEY]: groupSynced, + [VIRTUAL_HAS_LOCAL_KEY]: groupHasLocal, + ...rest + } = aggregatedRow as Record + + const origin: VirtualOrigin = groupHasLocal ? `local` : `remote` + return [ finalKey, { - ...aggregatedRow, + ...rest, $selected: finalResults, + $synced: groupSynced ?? true, + $origin: origin, + $key: finalKey, + $collectionId: aggregateCollectionId ?? rest.$collectionId, }, ] as [unknown, Record] }), diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index b1d306607..69bdc6518 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -8,6 +8,7 @@ import { LimitOffsetRequireOrderByError, UnsupportedFromTypeError, } from '../../errors.js' +import { VIRTUAL_PROP_NAMES } from '../../virtual-props.js' import { PropRef, Value as ValClass, getWhereExpression } from '../ir.js' import { compileExpression, toBooleanPredicate } from './evaluators.js' import { processJoins } from './joins.js' @@ -264,6 +265,7 @@ export function compileQuery( query.having, query.select, query.fnHaving, + mainCollectionId, ) } else if (query.select) { // Check if SELECT contains aggregates but no GROUP BY (implicit single-group aggregation) @@ -278,6 +280,7 @@ export function compileQuery( query.having, query.select, query.fnHaving, + mainCollectionId, ) } } @@ -334,7 +337,10 @@ export function compileQuery( map(([key, [row, orderByIndex]]) => { // Extract the final results from $selected and include orderBy index const raw = (row as any).$selected - const finalResults = unwrapValue(raw) + const finalResults = attachVirtualPropsToSelected( + unwrapValue(raw), + row as Record, + ) return [key, [finalResults, orderByIndex]] as [unknown, [any, string]] }), ) @@ -361,7 +367,10 @@ export function compileQuery( map(([key, row]) => { // Extract the final results from $selected and return [key, [results, undefined]] const raw = (row as any).$selected - const finalResults = unwrapValue(raw) + const finalResults = attachVirtualPropsToSelected( + unwrapValue(raw), + row as Record, + ) return [key, [finalResults, undefined]] as [ unknown, [any, string | undefined], @@ -588,6 +597,36 @@ function unwrapValue(value: any): any { return isValue(value) ? value.value : value } +function attachVirtualPropsToSelected( + selected: any, + row: Record, +): any { + if (!selected || typeof selected !== `object`) { + return selected + } + + let needsMerge = false + for (const prop of VIRTUAL_PROP_NAMES) { + if (selected[prop] == null && prop in row) { + needsMerge = true + break + } + } + + if (!needsMerge) { + return selected + } + + const merged = { ...selected } + for (const prop of VIRTUAL_PROP_NAMES) { + if (merged[prop] == null && prop in row) { + merged[prop] = row[prop] + } + } + + return merged +} + /** * Recursively maps optimized subqueries to their original queries for proper caching. * This ensures that when we encounter the same QueryRef object in different contexts, diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 1598382d8..9d84a1099 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -5,6 +5,7 @@ import type { Transaction } from './transactions' import type { BasicExpression, OrderBy } from './query/ir.js' import type { EventEmitter } from './event-emitter.js' import type { SingleRowRefProxy } from './query/builder/ref-proxy.js' +import type { WithVirtualProps } from './virtual-props.js' /** * Interface for a collection-like object that provides the necessary methods @@ -739,9 +740,10 @@ export type CollectionConfigSingleRowOption< TUtils extends UtilsRecord = {}, > = CollectionConfig & MaybeSingleResult -export type ChangesPayload> = Array< - ChangeMessage -> +export type ChangesPayload< + T extends object = Record, + TKey extends string | number = string | number, +> = Array, TKey>> /** * An input row from a collection @@ -783,6 +785,7 @@ export type NamespacedAndKeyedStream = IStreamBuilder */ export interface SubscribeChangesOptions< T extends object = Record, + TKey extends string | number = string | number, > { /** Whether to include the current state as initial changes */ includeInitialState?: boolean @@ -800,7 +803,7 @@ export interface SubscribeChangesOptions< * }) * ``` */ - where?: (row: SingleRowRefProxy) => any + where?: (row: SingleRowRefProxy>) => any /** Pre-compiled expression for filtering changes */ whereExpression?: BasicExpression /** @@ -829,7 +832,8 @@ export interface SubscribeChangesOptions< export interface SubscribeChangesSnapshotOptions< T extends object = Record, -> extends Omit, `includeInitialState`> { + TKey extends string | number = string | number, +> extends Omit, `includeInitialState`> { orderBy?: OrderBy limit?: number } @@ -879,7 +883,7 @@ export interface CurrentStateAsChangesOptions { export type ChangeListener< T extends object = Record, TKey extends string | number = string | number, -> = (changes: Array>) => void +> = (changes: Array, TKey>>) => void // Adapted from https://github.com/sindresorhus/type-fest // MIT License Copyright (c) Sindre Sorhus diff --git a/packages/db/src/utils/index-optimization.ts b/packages/db/src/utils/index-optimization.ts index f6c26ff91..81b111af5 100644 --- a/packages/db/src/utils/index-optimization.ts +++ b/packages/db/src/utils/index-optimization.ts @@ -17,6 +17,7 @@ import { DEFAULT_COMPARE_OPTIONS } from '../utils.js' import { ReverseIndex } from '../indexes/reverse-index.js' +import { hasVirtualPropPath } from '../virtual-props.js' import type { CompareOptions } from '../query/builder/types.js' import type { IndexInterface, IndexOperation } from '../indexes/base-index.js' import type { BasicExpression } from '../query/ir.js' @@ -38,6 +39,9 @@ export function findIndexForField( fieldPath: Array, compareOptions?: CompareOptions, ): IndexInterface | undefined { + if (hasVirtualPropPath(fieldPath)) { + return undefined + } const compareOpts = compareOptions ?? { ...DEFAULT_COMPARE_OPTIONS, ...collection.compareOptions, diff --git a/packages/db/src/virtual-props.ts b/packages/db/src/virtual-props.ts new file mode 100644 index 000000000..7ef768824 --- /dev/null +++ b/packages/db/src/virtual-props.ts @@ -0,0 +1,299 @@ +/** + * Virtual Properties for TanStack DB + * + * Virtual properties are computed, read-only properties that provide metadata about rows + * (sync status, source, selection state) without being part of the persisted data model. + * + * Virtual properties are prefixed with `$` to distinguish them from user data fields. + * User schemas should not include `$`-prefixed fields as they are reserved. + */ + +/** + * Origin of the last confirmed change to a row, from the current client's perspective. + * + * - `'local'`: The change originated from this client (e.g., a mutation made here) + * - `'remote'`: The change was received via sync from another client/server + * + * Note: This reflects the client's perspective, not the original creator. + * User A creates order → $origin = 'local' on User A's client + * Order syncs to server + * User B receives order → $origin = 'remote' on User B's client + */ +export type VirtualOrigin = 'local' | 'remote' + +/** + * Virtual properties available on every row in TanStack DB collections. + * + * These properties are: + * - Computed (not stored in the data model) + * - Read-only (cannot be mutated directly) + * - Available in queries (WHERE, ORDER BY, SELECT) + * - Included when spreading rows (`...user`) + * + * @template TKey - The type of the row's key (string or number) + * + * @example + * ```typescript + * // Accessing virtual properties on a row + * const user = collection.get('user-1') + * if (user.$synced) { + * console.log('Confirmed by backend') + * } + * if (user.$origin === 'local') { + * console.log('Created/modified locally') + * } + * ``` + * + * @example + * ```typescript + * // Using virtual properties in queries + * const confirmedOrders = createLiveQueryCollection({ + * query: (q) => q + * .from({ order: orders }) + * .where(({ order }) => eq(order.$synced, true)) + * }) + * ``` + */ +export interface VirtualRowProps< + TKey extends string | number = string | number, +> { + /** + * Whether this row reflects confirmed state from the backend. + * + * - `true`: Row is confirmed by the backend (no pending optimistic mutations) + * - `false`: Row has pending optimistic mutations that haven't been confirmed + * + * For local-only collections (no sync), this is always `true`. + * For live query collections, this is passed through from the source collection. + */ + readonly $synced: boolean + + /** + * Origin of the last confirmed change to this row, from the current client's perspective. + * + * - `'local'`: The change originated from this client + * - `'remote'`: The change was received via sync + * + * For local-only collections, this is always `'local'`. + * For live query collections, this is passed through from the source collection. + */ + readonly $origin: VirtualOrigin + + /** + * The row's key (primary identifier). + * + * This is the same value returned by `collection.config.getKey(row)`. + * Useful when you need the key in projections or computations. + */ + readonly $key: TKey + + /** + * The ID of the source collection this row originated from. + * + * In joins, this can help identify which collection each row came from. + * For live query collections, this is the ID of the upstream collection. + */ + readonly $collectionId: string +} + +/** + * Virtual properties as ref types for use in query expressions. + * These are the types used when accessing virtual properties in query callbacks. + * + * @internal + */ +export type VirtualRefProps = { + readonly $synced: boolean + readonly $origin: VirtualOrigin + readonly $key: TKey + readonly $collectionId: string +} + +/** + * Adds virtual properties to a row type. + * + * @template T - The base row type + * @template TKey - The type of the row's key + * + * @example + * ```typescript + * type User = { id: string; name: string } + * type UserWithVirtual = WithVirtualProps + * // { id: string; name: string; $synced: boolean; $origin: 'local' | 'remote'; $key: string; $collectionId: string } + * ``` + */ +export type WithVirtualProps< + T extends object, + TKey extends string | number = string | number, +> = T & VirtualRowProps + +/** + * Extracts the base type from a type that may have virtual properties. + * Useful when you need to work with the raw data without virtual properties. + * + * @template T - The type that may include virtual properties + * + * @example + * ```typescript + * type UserWithVirtual = { id: string; name: string; $synced: boolean; $origin: 'local' | 'remote' } + * type User = WithoutVirtualProps + * // { id: string; name: string } + * ``` + */ +export type WithoutVirtualProps = Omit< + T, + '$synced' | '$origin' | '$key' | '$collectionId' +> + +/** + * Checks if a value has virtual properties attached. + * + * @param value - The value to check + * @returns true if the value has virtual properties + * + * @example + * ```typescript + * if (hasVirtualProps(row)) { + * console.log('Synced:', row.$synced) + * } + * ``` + */ +export function hasVirtualProps( + value: unknown, +): value is VirtualRowProps { + return ( + typeof value === 'object' && + value !== null && + '$synced' in value && + '$origin' in value + ) +} + +/** + * Creates virtual properties for a row in a source collection. + * + * This is the internal function used by collections to add virtual properties + * to rows when emitting change messages. + * + * @param key - The row's key + * @param collectionId - The collection's ID + * @param isSynced - Whether the row is synced (not optimistic) + * @param origin - Whether the change was local or remote + * @returns Virtual properties object to merge with the row + * + * @internal + */ +export function createVirtualProps( + key: TKey, + collectionId: string, + isSynced: boolean, + origin: VirtualOrigin, +): VirtualRowProps { + return { + $synced: isSynced, + $origin: origin, + $key: key, + $collectionId: collectionId, + } +} + +/** + * Enriches a row with virtual properties using the "add-if-missing" pattern. + * + * If the row already has virtual properties (from an upstream collection), + * they are preserved. If not, new virtual properties are computed and added. + * + * This is the key function that enables pass-through semantics for nested + * live query collections. + * + * @param row - The row to enrich + * @param key - The row's key + * @param collectionId - The collection's ID + * @param computeSynced - Function to compute $synced if missing + * @param computeOrigin - Function to compute $origin if missing + * @returns The row with virtual properties (possibly the same object if already present) + * + * @internal + */ +export function enrichRowWithVirtualProps< + T extends object, + TKey extends string | number, +>( + row: T, + key: TKey, + collectionId: string, + computeSynced: () => boolean, + computeOrigin: () => VirtualOrigin, +): WithVirtualProps { + // Use nullish coalescing to preserve existing virtual properties (pass-through) + // This is the "add-if-missing" pattern described in the RFC + const existingRow = row as Partial> + + return { + ...row, + $synced: existingRow.$synced ?? computeSynced(), + $origin: existingRow.$origin ?? computeOrigin(), + $key: existingRow.$key ?? key, + $collectionId: existingRow.$collectionId ?? collectionId, + } as WithVirtualProps +} + +/** + * Computes aggregate virtual properties for a group of rows. + * + * For aggregates: + * - `$synced`: true if ALL rows in the group are synced; false if ANY row is optimistic + * - `$origin`: 'local' if ANY row in the group is local; otherwise 'remote' + * + * @param rows - The rows in the group + * @param groupKey - The group key + * @param collectionId - The collection ID + * @returns Virtual properties for the aggregate row + * + * @internal + */ +export function computeAggregateVirtualProps( + rows: Array>>, + groupKey: TKey, + collectionId: string, +): VirtualRowProps { + // $synced = true only if ALL rows are synced (false if ANY is optimistic) + const allSynced = rows.every((row) => row.$synced ?? true) + + // $origin = 'local' if ANY row is local (consistent with "local influence" semantics) + const hasLocal = rows.some((row) => row.$origin === 'local') + + return { + $synced: allSynced, + $origin: hasLocal ? 'local' : 'remote', + $key: groupKey, + $collectionId: collectionId, + } +} + +/** + * List of virtual property names for iteration and checking. + * @internal + */ +export const VIRTUAL_PROP_NAMES = [ + '$synced', + '$origin', + '$key', + '$collectionId', +] as const + +/** + * Checks if a property name is a virtual property. + * @internal + */ +export function isVirtualPropName(name: string): boolean { + return VIRTUAL_PROP_NAMES.includes(name as any) +} + +/** + * Checks whether a property path references a virtual property. + * @internal + */ +export function hasVirtualPropPath(path: Array): boolean { + return path.some((segment) => isVirtualPropName(segment)) +} diff --git a/packages/db/tests/collection-getters.test.ts b/packages/db/tests/collection-getters.test.ts index f5f8dda41..faad77927 100644 --- a/packages/db/tests/collection-getters.test.ts +++ b/packages/db/tests/collection-getters.test.ts @@ -1,11 +1,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createTransaction } from '../src/transactions' import { createCollection } from '../src/collection/index.js' +import { stripVirtualProps } from './utils' import type { CollectionImpl } from '../src/collection/index.js' import type { SyncConfig } from '../src/types' type Item = { id: string; name: string } +const stripValues = (values: Array): Array => + values.map((value) => stripVirtualProps(value)) + +const stripEntries = ( + entries: Array<[TKey, T]>, +): Array<[TKey, T]> => + entries.map(([key, value]) => [key, stripVirtualProps(value)]) + describe(`Collection getters`, () => { let collection: CollectionImpl let mockSync: SyncConfig @@ -42,11 +51,11 @@ describe(`Collection getters`, () => { const state = collection.state expect(state).toBeInstanceOf(Map) expect(state.size).toBe(2) - expect(state.get(`item1`)).toEqual({ + expect(stripVirtualProps(state.get(`item1`))).toEqual({ id: `item1`, name: `Item 1`, }) - expect(state.get(`item2`)).toEqual({ + expect(stripVirtualProps(state.get(`item2`))).toEqual({ id: `item2`, name: `Item 2`, }) @@ -256,7 +265,7 @@ describe(`Collection getters`, () => { describe(`values method`, () => { it(`returns all values as an iterator`, () => { - const values = Array.from(collection.values()) + const values = stripValues(Array.from(collection.values())) expect(values).toHaveLength(2) expect(values).toContainEqual({ id: `item1`, name: `Item 1` }) expect(values).toContainEqual({ id: `item2`, name: `Item 2` }) @@ -268,7 +277,7 @@ describe(`Collection getters`, () => { const tx = createTransaction({ mutationFn }) tx.mutate(() => collection.delete(`item1`)) - const values = Array.from(collection.values()) + const values = stripValues(Array.from(collection.values())) expect(values).toHaveLength(1) expect(values).toContainEqual({ id: `item2`, name: `Item 2` }) expect(values).not.toContainEqual({ id: `item1`, name: `Item 1` }) @@ -280,7 +289,7 @@ describe(`Collection getters`, () => { const tx = createTransaction({ mutationFn }) tx.mutate(() => collection.insert({ id: `item3`, name: `Item 3` })) - const values = Array.from(collection.values()) + const values = stripValues(Array.from(collection.values())) expect(values).toHaveLength(3) expect(values).toContainEqual({ id: `item1`, name: `Item 1` }) expect(values).toContainEqual({ id: `item2`, name: `Item 2` }) @@ -297,7 +306,7 @@ describe(`Collection getters`, () => { }), ) - const values = Array.from(collection.values()) + const values = stripValues(Array.from(collection.values())) expect(values).toHaveLength(2) expect(values).toContainEqual({ id: `item1`, name: `Updated Item 1` }) expect(values).toContainEqual({ id: `item2`, name: `Item 2` }) @@ -306,7 +315,7 @@ describe(`Collection getters`, () => { describe(`entries method`, () => { it(`returns all entries as an iterator`, () => { - const entries = Array.from(collection.entries()) + const entries = stripEntries(Array.from(collection.entries())) expect(entries).toHaveLength(2) expect(entries).toContainEqual([`item1`, { id: `item1`, name: `Item 1` }]) expect(entries).toContainEqual([`item2`, { id: `item2`, name: `Item 2` }]) @@ -318,7 +327,7 @@ describe(`Collection getters`, () => { const tx = createTransaction({ mutationFn }) tx.mutate(() => collection.delete(`item1`)) - const entries = Array.from(collection.entries()) + const entries = stripEntries(Array.from(collection.entries())) expect(entries).toHaveLength(1) expect(entries).toContainEqual([`item2`, { id: `item2`, name: `Item 2` }]) }) @@ -329,7 +338,7 @@ describe(`Collection getters`, () => { const tx = createTransaction({ mutationFn }) tx.mutate(() => collection.insert({ id: `item3`, name: `Item 3` })) - const entries = Array.from(collection.entries()) + const entries = stripEntries(Array.from(collection.entries())) expect(entries).toHaveLength(3) expect(entries).toContainEqual([`item1`, { id: `item1`, name: `Item 1` }]) expect(entries).toContainEqual([`item2`, { id: `item2`, name: `Item 2` }]) @@ -346,7 +355,7 @@ describe(`Collection getters`, () => { }), ) - const entries = Array.from(collection.entries()) + const entries = stripEntries(Array.from(collection.entries())) expect(entries).toHaveLength(2) expect(entries).toContainEqual([ `item1`, @@ -360,7 +369,7 @@ describe(`Collection getters`, () => { it(`returns the correct value for existing items`, () => { const key = `item1` const value = collection.get(key) - expect(value).toEqual({ id: `item1`, name: `Item 1` }) + expect(stripVirtualProps(value)).toEqual({ id: `item1`, name: `Item 1` }) }) it(`returns undefined for non-existing items`, () => { @@ -377,7 +386,7 @@ describe(`Collection getters`, () => { const key = `item3` const value = collection.get(key) - expect(value).toEqual({ id: `item3`, name: `Item 3` }) + expect(stripVirtualProps(value)).toEqual({ id: `item3`, name: `Item 3` }) }) it(`returns undefined for optimistically deleted items`, () => { @@ -403,7 +412,10 @@ describe(`Collection getters`, () => { const key = `item1` const value = collection.get(key) - expect(value).toEqual({ id: `item1`, name: `Updated Item 1` }) + expect(stripVirtualProps(value)).toEqual({ + id: `item1`, + name: `Updated Item 1`, + }) }) }) @@ -453,7 +465,7 @@ describe(`Collection getters`, () => { // Now the promise should resolve const state = await statePromise expect(state).toBeInstanceOf(Map) - expect(state.get(`delayed-item`)).toEqual({ + expect(stripVirtualProps(state.get(`delayed-item`))).toEqual({ id: `delayed-item`, name: `Delayed Item`, }) @@ -462,7 +474,7 @@ describe(`Collection getters`, () => { describe(`toArray getter`, () => { it(`returns the current state as an array`, () => { - const array = collection.toArray + const array = stripValues(collection.toArray) expect(Array.isArray(array)).toBe(true) expect(array.length).toBe(2) expect(array).toContainEqual({ id: `item1`, name: `Item 1` }) @@ -473,7 +485,7 @@ describe(`Collection getters`, () => { describe(`toArrayWhenReady`, () => { it(`resolves immediately if data is already available`, async () => { const arrayPromise = collection.toArrayWhenReady() - const array = await arrayPromise + const array = stripValues(await arrayPromise) expect(Array.isArray(array)).toBe(true) expect(array.length).toBe(2) }) @@ -517,7 +529,10 @@ describe(`Collection getters`, () => { // Now the promise should resolve const array = await arrayPromise expect(Array.isArray(array)).toBe(true) - expect(array).toContainEqual({ id: `delayed-item`, name: `Delayed Item` }) + expect(array.map((row) => stripVirtualProps(row))).toContainEqual({ + id: `delayed-item`, + name: `Delayed Item`, + }) }) }) }) diff --git a/packages/db/tests/collection-indexes.test.ts b/packages/db/tests/collection-indexes.test.ts index a8e3896fd..48cef9e79 100644 --- a/packages/db/tests/collection-indexes.test.ts +++ b/packages/db/tests/collection-indexes.test.ts @@ -14,10 +14,24 @@ import { or, } from '../src/query/builder/functions' import { PropRef } from '../src/query/ir' -import { expectIndexUsage, withIndexTracking } from './utils' +import { expectIndexUsage, stripVirtualProps, withIndexTracking } from './utils' import type { Collection } from '../src/collection/index.js' import type { MutationFn, PendingMutation } from '../src/types' +const normalizeChange = (change: any) => ({ + ...change, + value: stripVirtualProps(change.value), + previousValue: stripVirtualProps(change.previousValue), +}) + +const stripVirtualOnlyUpdates = (changes: Array) => + changes.map(normalizeChange).filter((change) => { + if (change.type !== `update`) { + return true + } + return JSON.stringify(change.value) !== JSON.stringify(change.previousValue) + }) + interface TestItem { id: string name: string @@ -212,12 +226,13 @@ describe(`Collection Indexes`, () => { // Item should be in collection state expect(collection.size).toBe(6) - expect(collection.get(`6`)).toEqual(newItem) + expect(stripVirtualProps(collection.get(`6`))).toEqual(newItem) - // Should trigger subscription - expect(changes).toHaveLength(1) - expect(changes[0]?.type).toBe(`insert`) - expect(changes[0]?.value.name).toBe(`Frank`) + // Should trigger subscription (ignore virtual-only confirmation update) + const dataChanges = stripVirtualOnlyUpdates(changes) + expect(dataChanges).toHaveLength(1) + expect(dataChanges[0]?.type).toBe(`insert`) + expect(dataChanges[0]?.value.name).toBe(`Frank`) subscription.unsubscribe() }) @@ -251,10 +266,11 @@ describe(`Collection Indexes`, () => { expect(updatedItem?.status).toBe(`inactive`) expect(updatedItem?.age).toBe(26) - // Should trigger subscription - expect(changes).toHaveLength(1) - expect(changes[0]?.type).toBe(`update`) - expect(changes[0]?.value.status).toBe(`inactive`) + // Should trigger subscription (ignore virtual-only confirmation update) + const dataChanges = stripVirtualOnlyUpdates(changes) + expect(dataChanges).toHaveLength(1) + expect(dataChanges[0]?.type).toBe(`update`) + expect(dataChanges[0]?.value.status).toBe(`inactive`) subscription.unsubscribe() }) @@ -280,10 +296,11 @@ describe(`Collection Indexes`, () => { expect(updatedItem?.status).toBe(`inactive`) expect(updatedItem?.age).toBe(26) - // Should trigger subscription - expect(changes).toHaveLength(1) - expect(changes[0]?.type).toBe(`insert`) - expect(changes[0]?.value.status).toBe(`inactive`) + // Should trigger subscription (ignore virtual-only confirmation update) + const dataChanges = stripVirtualOnlyUpdates(changes) + expect(dataChanges).toHaveLength(1) + expect(dataChanges[0]?.type).toBe(`insert`) + expect(dataChanges[0]?.value.status).toBe(`inactive`) subscription.unsubscribe() }) @@ -371,8 +388,9 @@ describe(`Collection Indexes`, () => { ) await tx1.isPersisted.promise - expect(activeChanges).toHaveLength(1) - expect(activeChanges[0]?.value.name).toBe(`Bob`) + const dataChanges = stripVirtualOnlyUpdates(activeChanges) + expect(dataChanges).toHaveLength(1) + expect(dataChanges[0]?.value.name).toBe(`Bob`) // Change active item to inactive (should trigger delete event for item leaving filter) activeChanges.length = 0 @@ -385,10 +403,11 @@ describe(`Collection Indexes`, () => { await tx2.isPersisted.promise // Should trigger delete event for item that no longer matches filter - expect(activeChanges).toHaveLength(1) - expect(activeChanges[0]?.type).toBe(`delete`) - expect(activeChanges[0]?.key).toBe(`1`) - expect(activeChanges[0]?.value.status).toBe(`active`) // Should be the previous value + const filteredChanges = stripVirtualOnlyUpdates(activeChanges) + expect(filteredChanges).toHaveLength(1) + expect(filteredChanges[0]?.type).toBe(`delete`) + expect(filteredChanges[0]?.key).toBe(`1`) + expect(filteredChanges[0]?.value.status).toBe(`active`) // Should be the previous value subscription.unsubscribe() }) @@ -414,8 +433,9 @@ describe(`Collection Indexes`, () => { ) await tx1.isPersisted.promise - expect(activeChanges).toHaveLength(1) - expect(activeChanges[0]?.value.name).toBe(`Bob`) + const dataChanges = stripVirtualOnlyUpdates(activeChanges) + expect(dataChanges).toHaveLength(1) + expect(dataChanges[0]?.value.name).toBe(`Bob`) // Change active item to inactive (should trigger delete event for item leaving filter) activeChanges.length = 0 @@ -430,7 +450,8 @@ describe(`Collection Indexes`, () => { // Subscriber shoiuld not receive any changes // because it is not aware of that key // so it should also not receive the delete of that key - expect(activeChanges).toHaveLength(0) + const filteredChanges = stripVirtualOnlyUpdates(activeChanges) + expect(filteredChanges).toHaveLength(0) subscription.unsubscribe() }) @@ -1175,8 +1196,9 @@ describe(`Collection Indexes`, () => { ) await tx1.isPersisted.promise - expect(changes).toHaveLength(1) - expect(changes[0]?.value.name).toBe(`Frank`) + const dataChanges = stripVirtualOnlyUpdates(changes) + expect(dataChanges).toHaveLength(1) + expect(dataChanges[0]?.value.name).toBe(`Frank`) // Add an inactive item (should not trigger) changes.length = 0 @@ -1251,8 +1273,9 @@ describe(`Collection Indexes`, () => { ) await tx.isPersisted.promise - expect(changes).toHaveLength(1) - expect(changes[0]?.value.name).toBe(`Diana`) + const dataChanges = stripVirtualOnlyUpdates(changes) + expect(dataChanges).toHaveLength(1) + expect(dataChanges[0]?.value.name).toBe(`Diana`) subscription.unsubscribe() }) diff --git a/packages/db/tests/collection-subscribe-changes.test.ts b/packages/db/tests/collection-subscribe-changes.test.ts index 02e987e1e..63550b27a 100644 --- a/packages/db/tests/collection-subscribe-changes.test.ts +++ b/packages/db/tests/collection-subscribe-changes.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import mitt from 'mitt' import { createCollection } from '../src/collection/index.js' +import { createLiveQueryCollection } from '../src/query/live-query-collection.js' +import { localOnlyCollectionOptions } from '../src/local-only.js' import { createTransaction } from '../src/transactions' -import { and, eq, gt } from '../src/query/builder/functions' +import { and, count, eq, gt } from '../src/query/builder/functions' import { PropRef } from '../src/query/ir' +import { stripVirtualProps } from './utils' +import type { OutputWithVirtual } from './utils' import type { ChangeMessage, ChangesPayload, @@ -15,6 +19,32 @@ import type { // Helper function to wait for changes to be processed const waitForChanges = () => new Promise((resolve) => setTimeout(resolve, 10)) +const normalizeChange = >( + change: ChangeMessage, +): ChangeMessage => ({ + ...change, + value: stripVirtualProps(change.value), + previousValue: change.previousValue + ? stripVirtualProps(change.previousValue) + : undefined, +}) + +const normalizeChanges = >( + changes: Array>, +) => changes.map(normalizeChange) + +const normalizeChangesWithoutVirtualUpdates = >( + changes: Array>, +) => + normalizeChanges(changes).filter((change) => { + if (change.type !== `update`) { + return true + } + const value = stripVirtualProps(change.value) + const previousValue = stripVirtualProps(change.previousValue) + return JSON.stringify(value) !== JSON.stringify(previousValue) + }) + describe(`Collection.subscribeChanges`, () => { it(`should emit initial collection state as insert changes`, () => { const callback = vi.fn() @@ -160,7 +190,10 @@ describe(`Collection.subscribeChanges`, () => { }> expect(insertChange).toBeDefined() expect(insertChange.type).toBe(`insert`) - expect(insertChange.value).toEqual({ id: 1, value: `sync value 1` }) + expect(stripVirtualProps(insertChange.value)).toEqual({ + id: 1, + value: `sync value 1`, + }) // Reset mock callback.mockReset() @@ -185,7 +218,10 @@ describe(`Collection.subscribeChanges`, () => { }> expect(updateChange).toBeDefined() expect(updateChange.type).toBe(`update`) - expect(updateChange.value).toEqual({ id: 1, value: `updated sync value` }) + expect(stripVirtualProps(updateChange.value)).toEqual({ + id: 1, + value: `updated sync value`, + }) // Reset mock callback.mockReset() @@ -275,7 +311,7 @@ describe(`Collection.subscribeChanges`, () => { value: string }> expect(insertChange).toBeDefined() - expect(insertChange).toEqual({ + expect(normalizeChange(insertChange)).toEqual({ key: 1, type: `insert`, value: { id: 1, value: `optimistic value` }, @@ -313,7 +349,7 @@ describe(`Collection.subscribeChanges`, () => { }> expect(updateChange).toBeDefined() expect(updateChange.type).toBe(`update`) - expect(updateChange.value).toEqual({ + expect(stripVirtualProps(updateChange.value)).toEqual({ id: 1, value: `updated optimistic value`, updated: true, @@ -399,7 +435,7 @@ describe(`Collection.subscribeChanges`, () => { // Verify synced insert was emitted expect(callback).toHaveBeenCalledTimes(1) - expect(callback.mock.calls[0]![0]).toEqual([ + expect(normalizeChanges(callback.mock.calls[0]![0])).toEqual([ { key: 1, type: `insert`, @@ -414,7 +450,7 @@ describe(`Collection.subscribeChanges`, () => { // Verify optimistic insert was emitted - this is the synchronous optimistic update expect(callback).toHaveBeenCalledTimes(1) - expect(callback.mock.calls[0]![0]).toEqual([ + expect(normalizeChanges(callback.mock.calls[0]![0])).toEqual([ { key: 2, type: `insert`, @@ -425,8 +461,11 @@ describe(`Collection.subscribeChanges`, () => { await tx.isPersisted.promise - // Verify no changes were emitted as the sync should match the optimistic state - expect(callback).toHaveBeenCalledTimes(0) + // Sync confirmation should only change virtual props ($synced/$origin) + expect(callback).toHaveBeenCalledTimes(1) + expect( + normalizeChangesWithoutVirtualUpdates(callback.mock.calls[0]![0]), + ).toEqual([]) callback.mockReset() // Update both items in optimistic and synced ways @@ -442,7 +481,7 @@ describe(`Collection.subscribeChanges`, () => { // Verify the optimistic update was emitted expect(callback).toHaveBeenCalledTimes(1) - expect(callback.mock.calls[0]![0]).toEqual([ + expect(normalizeChanges(callback.mock.calls[0]![0])).toEqual([ { type: `update`, key: 2, @@ -460,8 +499,11 @@ describe(`Collection.subscribeChanges`, () => { await updateTx.isPersisted.promise - // Verify no redundant sync events were emitted - expect(callback).toHaveBeenCalledTimes(0) + // Sync confirmation should only change virtual props ($synced/$origin) + expect(callback).toHaveBeenCalledTimes(1) + expect( + normalizeChangesWithoutVirtualUpdates(callback.mock.calls[0]![0]), + ).toEqual([]) callback.mockReset() // Then update the synced item with a synced update @@ -486,7 +528,10 @@ describe(`Collection.subscribeChanges`, () => { }> expect(updateChange).toBeDefined() expect(updateChange.type).toBe(`update`) - expect(updateChange.value).toEqual({ id: 1, value: `updated synced value` }) + expect(stripVirtualProps(updateChange.value)).toEqual({ + id: 1, + value: `updated synced value`, + }) // Clean up subscription.unsubscribe() @@ -574,8 +619,11 @@ describe(`Collection.subscribeChanges`, () => { // Wait for changes to propagate await waitForChanges() - // Verify no changes were emitted as the sync should match the optimistic state - expect(callback).toHaveBeenCalledTimes(0) + // Sync confirmation should only change virtual props ($synced/$origin) + expect(callback).toHaveBeenCalledTimes(1) + expect( + normalizeChangesWithoutVirtualUpdates(callback.mock.calls[0]![0]), + ).toEqual([]) callback.mockReset() // Update one item only @@ -856,8 +904,14 @@ describe(`Collection.subscribeChanges`, () => { // Verify initial state expect(collection.state.size).toBe(2) - expect(collection.state.get(1)).toEqual({ id: 1, value: `initial value 1` }) - expect(collection.state.get(2)).toEqual({ id: 2, value: `initial value 2` }) + expect(stripVirtualProps(collection.state.get(1))).toEqual({ + id: 1, + value: `initial value 1`, + }) + expect(stripVirtualProps(collection.state.get(2))).toEqual({ + id: 2, + value: `initial value 2`, + }) expect(changeEvents).toHaveLength(2) @@ -875,12 +929,12 @@ describe(`Collection.subscribeChanges`, () => { // Verify delete events were emitted for all existing items expect(changeEvents).toHaveLength(2) - expect(changeEvents[0]).toEqual({ + expect(normalizeChange(changeEvents[0])).toEqual({ type: `delete`, key: 1, value: { id: 1, value: `initial value 1` }, }) - expect(changeEvents[1]).toEqual({ + expect(normalizeChange(changeEvents[1])).toEqual({ type: `delete`, key: 2, value: { id: 2, value: `initial value 2` }, @@ -955,11 +1009,11 @@ describe(`Collection.subscribeChanges`, () => { // Verify collection is cleared // After truncate, preserved optimistic inserts should be re-applied expect(collection.state.size).toBe(2) - expect(collection.state.get(1)).toEqual({ + expect(stripVirtualProps(collection.state.get(1))).toEqual({ id: 1, value: `optimistic update 1`, }) - expect(collection.state.get(3)).toEqual({ + expect(stripVirtualProps(collection.state.get(3))).toEqual({ id: 3, value: `optimistic insert`, }) @@ -974,29 +1028,29 @@ describe(`Collection.subscribeChanges`, () => { const deleteByKey = new Map(deletes.map((e) => [e.key, e])) const insertByKey = new Map(inserts.map((e) => [e.key, e])) - expect(deleteByKey.get(1)).toEqual({ + expect(normalizeChange(deleteByKey.get(1))).toEqual({ type: `delete`, key: 1, value: { id: 1, value: `optimistic update 1` }, }) - expect(deleteByKey.get(2)).toEqual({ + expect(normalizeChange(deleteByKey.get(2))).toEqual({ type: `delete`, key: 2, value: { id: 2, value: `initial value 2` }, }) - expect(deleteByKey.get(3)).toEqual({ + expect(normalizeChange(deleteByKey.get(3))).toEqual({ type: `delete`, key: 3, value: { id: 3, value: `optimistic insert` }, }) // Insert events for preserved optimistic entries (1 and 3) - expect(insertByKey.get(1)).toEqual({ + expect(normalizeChange(insertByKey.get(1))).toEqual({ type: `insert`, key: 1, value: { id: 1, value: `optimistic update 1` }, }) - expect(insertByKey.get(3)).toEqual({ + expect(normalizeChange(insertByKey.get(3))).toEqual({ type: `insert`, key: 3, value: { id: 3, value: `optimistic insert` }, @@ -1083,23 +1137,23 @@ describe(`Collection.subscribeChanges`, () => { // Verify new data is added correctly expect(collection.state.size).toBe(2) - expect(collection.state.get(3)).toEqual({ + expect(stripVirtualProps(collection.state.get(3))).toEqual({ id: 3, value: `new value after truncate`, }) - expect(collection.state.get(4)).toEqual({ + expect(stripVirtualProps(collection.state.get(4))).toEqual({ id: 4, value: `another new value`, }) // Verify insert events were emitted for new data expect(changeEvents).toHaveLength(2) - expect(changeEvents[0]).toEqual({ + expect(normalizeChange(changeEvents[0])).toEqual({ type: `insert`, key: 3, value: { id: 3, value: `new value after truncate` }, }) - expect(changeEvents[1]).toEqual({ + expect(normalizeChange(changeEvents[1])).toEqual({ type: `insert`, key: 4, value: { id: 4, value: `another new value` }, @@ -1197,19 +1251,22 @@ describe(`Collection.subscribeChanges`, () => { // Note: Previously there was a duplicate insert event that was incorrectly // being sent, causing 3 events. Now duplicates are filtered correctly. expect(changeEvents.length).toBe(2) - expect(changeEvents[0]).toEqual({ + expect(normalizeChange(changeEvents[0])).toEqual({ type: `delete`, key: 1, value: { id: 1, value: `client-update` }, }) - expect(changeEvents[1]).toEqual({ + expect(normalizeChange(changeEvents[1])).toEqual({ type: `insert`, key: 1, value: { id: 1, value: `client-update` }, }) // Final state reflects optimistic value - expect(collection.state.get(1)).toEqual({ id: 1, value: `client-update` }) + expect(stripVirtualProps(collection.state.get(1))).toEqual({ + id: 1, + value: `client-update`, + }) }) it(`truncate + optimistic delete: server reinserted key -> remains deleted (no duplicate delete event)`, async () => { @@ -1284,8 +1341,9 @@ describe(`Collection.subscribeChanges`, () => { commit() }, }, - onDelete: async ({ transaction }) => { + onDelete: ({ transaction }) => { emitter.emit(`sync`, transaction.mutations) + return Promise.resolve() }, }) @@ -1304,7 +1362,7 @@ describe(`Collection.subscribeChanges`, () => { const deleteChanges = callback.mock.calls[0]![0] as ChangesPayload<{ value: string }> - expect(deleteChanges).toEqual([ + expect(normalizeChanges(deleteChanges)).toEqual([ { type: `delete`, key: 1, @@ -1364,7 +1422,10 @@ describe(`Collection.subscribeChanges`, () => { e.value.value === `client-insert`, ), ).toBe(true) - expect(collection.state.get(2)).toEqual({ id: 2, value: `client-insert` }) + expect(stripVirtualProps(collection.state.get(2))).toEqual({ + id: 2, + value: `client-insert`, + }) }) it(`truncate + optimistic update: server did NOT reinsert key -> optimistic insert then update minimal`, async () => { @@ -1411,7 +1472,10 @@ describe(`Collection.subscribeChanges`, () => { expect( inserts.some((e) => e.key === 1 && e.value.value === `client-update`), ).toBe(true) - expect(collection.state.get(1)).toEqual({ id: 1, value: `client-update` }) + expect(stripVirtualProps(collection.state.get(1))).toEqual({ + id: 1, + value: `client-update`, + }) }) it(`truncate + optimistic delete: server did NOT reinsert key -> remains deleted (no extra event)`, async () => { @@ -1508,7 +1572,7 @@ describe(`Collection.subscribeChanges`, () => { await new Promise((resolve) => setTimeout(resolve, 10)) expect(callback.mock.calls.length).toBe(1) - expect(callback.mock.calls[0]![0]).toEqual([ + expect(normalizeChanges(callback.mock.calls[0]![0])).toEqual([ { type: `delete`, key: 0, @@ -1578,7 +1642,7 @@ describe(`Collection.subscribeChanges`, () => { await new Promise((resolve) => setTimeout(resolve, 10)) expect(callback.mock.calls.length).toBe(1) - expect(callback.mock.calls[0]![0]).toEqual([ + expect(normalizeChanges(callback.mock.calls[0]![0])).toEqual([ { type: `delete`, key: 0, @@ -1647,6 +1711,7 @@ describe(`Collection.subscribeChanges`, () => { expect(insertEvents.length).toBe(2) expect(updateEvents.length).toBe(2) } finally { + vi.useRealTimers() vi.restoreAllMocks() } }) @@ -1709,6 +1774,7 @@ describe(`Collection.subscribeChanges`, () => { value: { id: `x`, n: 1, foo: `abc` }, }) } finally { + vi.useRealTimers() vi.restoreAllMocks() } }) @@ -1743,12 +1809,12 @@ describe(`Collection.subscribeChanges`, () => { commit() expect(changeEvents).toHaveLength(2) - expect(changeEvents[0]).toEqual({ + expect(normalizeChange(changeEvents[0])).toEqual({ type: `insert`, key: 1, value: { id: 1, value: `first item` }, }) - expect(changeEvents[1]).toEqual({ + expect(normalizeChange(changeEvents[1])).toEqual({ type: `insert`, key: 2, value: { id: 2, value: `second item` }, @@ -1767,13 +1833,13 @@ describe(`Collection.subscribeChanges`, () => { commit() expect(changeEvents).toHaveLength(2) - expect(changeEvents[0]).toEqual({ + expect(normalizeChange(changeEvents[0])).toEqual({ type: `update`, key: 1, value: { id: 1, value: `first item updated` }, previousValue: { id: 1, value: `first item` }, }) - expect(changeEvents[1]).toEqual({ + expect(normalizeChange(changeEvents[1])).toEqual({ type: `insert`, key: 3, value: { id: 3, value: `third item` }, @@ -1790,7 +1856,7 @@ describe(`Collection.subscribeChanges`, () => { commit() expect(changeEvents).toHaveLength(1) - expect(changeEvents[0]).toEqual({ + expect(normalizeChange(changeEvents[0])).toEqual({ type: `delete`, key: 2, value: { id: 2, value: `second item` }, @@ -1809,11 +1875,14 @@ describe(`Collection.subscribeChanges`, () => { // Verify final state expect(collection.size).toBe(2) - expect(collection.state.get(1)).toEqual({ + expect(stripVirtualProps(collection.state.get(1))).toEqual({ id: 1, value: `first item updated`, }) - expect(collection.state.get(3)).toEqual({ id: 3, value: `third item` }) + expect(stripVirtualProps(collection.state.get(3))).toEqual({ + id: 3, + value: `third item`, + }) }) it(`should emit change events while collection is loading for filtered subscriptions`, () => { @@ -1861,7 +1930,7 @@ describe(`Collection.subscribeChanges`, () => { // Should only receive the active item expect(changeEvents).toHaveLength(1) - expect(changeEvents[0]).toEqual({ + expect(normalizeChange(changeEvents[0])).toEqual({ type: `insert`, key: 1, value: { id: 1, value: `active item`, active: true }, @@ -2056,7 +2125,7 @@ describe(`Collection.subscribeChanges`, () => { const initialChanges = callback.mock.calls[0]![0] as ChangesPayload expect(initialChanges).toHaveLength(1) expect(initialChanges[0]!.key).toBe(2) - expect(initialChanges[0]!.value).toEqual({ + expect(stripVirtualProps(initialChanges[0]!.value)).toEqual({ id: 2, value: `item2`, status: `active`, @@ -2082,3 +2151,473 @@ describe(`Collection.subscribeChanges`, () => { }).toThrow(`Cannot specify both 'where' and 'whereExpression' options`) }) }) + +describe(`Virtual properties`, () => { + it(`should include virtual properties in change messages`, async () => { + const changes: Array< + ChangeMessage> + > = [] + + const collection = createCollection<{ id: string; value: string }, string>({ + id: `virtual-props-change-test`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ + type: `insert`, + value: { id: `row-1`, value: `synced` }, + }) + commit() + markReady() + }, + }, + }) + + const subscription = collection.subscribeChanges( + (events) => changes.push(...events), + { includeInitialState: true }, + ) + + await waitForChanges() + + const insertChange = changes.find((change) => change.type === `insert`) + expect(insertChange).toBeDefined() + + const value = insertChange!.value as Record + expect(value.$synced).toBe(true) + expect(value.$origin).toBe(`remote`) + expect(value.$key).toBe(`row-1`) + expect(value.$collectionId).toBe(`virtual-props-change-test`) + + subscription.unsubscribe() + }) + + it(`should set $synced false and $origin local for optimistic inserts`, async () => { + const changes: Array> = [] + + const collection = createCollection<{ id: string; value: string }, string>({ + id: `virtual-props-optimistic-test`, + getKey: (item) => item.id, + sync: { + sync: ({ markReady }) => { + markReady() + }, + }, + onInsert: async () => { + await waitForChanges() + }, + }) + + const subscription = collection.subscribeChanges( + (events) => changes.push(...events), + { includeInitialState: false }, + ) + + collection.insert({ id: `opt-1`, value: `optimistic` }) + await waitForChanges() + + const insertChange = changes.find((change) => change.key === `opt-1`) + expect(insertChange).toBeDefined() + + const value = insertChange!.value as Record + expect(value.$synced).toBe(false) + expect(value.$origin).toBe(`local`) + + subscription.unsubscribe() + }) + + it(`should emit an update when $synced flips on confirmation`, async () => { + const changes: Array< + ChangeMessage> + > = [] + let syncFns: + | { + begin: () => void + write: (change: { + type: `insert` + value: { id: string; value: string } + }) => void + commit: () => void + } + | undefined + + const collection = createCollection<{ id: string; value: string }, string>({ + id: `virtual-props-confirmed-sync`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncFns = { begin, write, commit } + markReady() + }, + }, + onInsert: async () => { + await waitForChanges() + }, + }) + + const subscription = collection.subscribeChanges( + ( + events: Array< + ChangeMessage< + OutputWithVirtual<{ id: string; value: string }, string> + > + >, + ) => changes.push(...events), + { includeInitialState: false }, + ) + + collection.insert({ id: `row-1`, value: `optimistic` }) + await waitForChanges() + + const optimisticInsert = changes.find( + (change) => change.type === `insert` && change.key === `row-1`, + ) + expect(optimisticInsert).toBeDefined() + expect(optimisticInsert!.value.$synced).toBe(false) + + changes.length = 0 + + if (!syncFns) { + throw new Error(`Sync not ready`) + } + syncFns.begin() + syncFns.write({ + type: `insert`, + value: { id: `row-1`, value: `optimistic` }, + }) + syncFns.commit() + + await waitForChanges() + + const confirmedUpdate = changes.find( + (change) => change.type === `update` && change.key === `row-1`, + ) + expect(confirmedUpdate).toBeDefined() + expect(confirmedUpdate!.value.$synced).toBe(true) + expect(confirmedUpdate!.previousValue?.$synced).toBe(false) + + subscription.unsubscribe() + }) + + it(`should set $origin local for non-optimistic inserts`, async () => { + const changes: Array> = [] + let syncFns: + | { + begin: () => void + write: (change: { + type: `insert` + value: { id: string; value: string } + }) => void + commit: () => void + } + | undefined + + const collection = createCollection<{ id: string; value: string }, string>({ + id: `virtual-props-non-optimistic-origin`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncFns = { + begin, + write, + commit, + } + markReady() + }, + }, + onInsert: ({ transaction }) => { + if (!syncFns) { + throw new Error(`Sync not ready`) + } + syncFns.begin() + transaction.mutations.forEach((mutation) => { + syncFns!.write({ + type: `insert`, + value: mutation.modified as { id: string; value: string }, + }) + }) + syncFns.commit() + return Promise.resolve() + }, + }) + + const subscription = collection.subscribeChanges( + (events) => changes.push(...events), + { includeInitialState: false }, + ) + + collection.insert({ id: `local-1`, value: `local` }, { optimistic: false }) + await waitForChanges() + + const insertChange = changes.find((change) => change.key === `local-1`) + expect(insertChange).toBeDefined() + + const value = insertChange!.value as Record + expect(value.$synced).toBe(true) + expect(value.$origin).toBe(`local`) + + subscription.unsubscribe() + }) + + it(`should clear local origin after failed optimistic mutations`, async () => { + const changes: Array> = [] + let syncFns: + | { + begin: () => void + write: (change: { + type: `insert` + value: { id: string; value: string } + }) => void + commit: () => void + } + | undefined + + const collection = createCollection<{ id: string; value: string }, string>({ + id: `virtual-props-failed-optimistic-origin`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncFns = { + begin, + write, + commit, + } + markReady() + }, + }, + onInsert: () => Promise.reject(new Error(`insert failed`)), + }) + + const subscription = collection.subscribeChanges( + (events) => changes.push(...events), + { includeInitialState: false }, + ) + + const tx = collection.insert({ id: `row-1`, value: `optimistic` }) + try { + await tx.isPersisted.promise + } catch { + // Expected failure + } + + await waitForChanges() + + if (!syncFns) { + throw new Error(`Sync not ready`) + } + syncFns.begin() + syncFns.write({ + type: `insert`, + value: { id: `row-1`, value: `remote` }, + }) + syncFns.commit() + + await waitForChanges() + + const rowChanges = changes.filter((change) => change.key === `row-1`) + const latestChange = rowChanges[rowChanges.length - 1] + + expect(latestChange).toBeDefined() + const value = latestChange!.value as Record + expect(value.$synced).toBe(true) + expect(value.$origin).toBe(`remote`) + + subscription.unsubscribe() + }) + + it(`should pass through virtual properties in live query collections`, async () => { + const source = createCollection<{ id: string; active: boolean }, string>({ + id: `virtual-props-source`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ + type: `insert`, + value: { id: `row-1`, active: true }, + }) + commit() + markReady() + }, + }, + }) + + const live = createLiveQueryCollection({ + id: `virtual-props-live`, + query: (q) => + q.from({ item: source }).where(({ item }) => eq(item.active, true)), + }) + + const liveChanges: Array> = [] + const liveSub = live.subscribeChanges( + (events) => liveChanges.push(...events), + { includeInitialState: true }, + ) + + await waitForChanges() + + const liveRow = liveChanges[0]?.value as Record + expect(liveRow.$synced).toBe(true) + expect(liveRow.$origin).toBe(`remote`) + expect(liveRow.$collectionId).toBe(`virtual-props-source`) + + liveSub.unsubscribe() + await source.cleanup() + await live.cleanup() + }) + + it(`should allow filtering on $synced in live queries`, async () => { + const source = createCollection<{ id: string; value: string }, string>({ + id: `virtual-props-filter-source`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ + type: `insert`, + value: { id: `synced-1`, value: `synced` }, + }) + commit() + markReady() + }, + }, + onInsert: async () => { + await new Promise((resolve) => setTimeout(resolve, 50)) + }, + }) + + const syncedOnly = createLiveQueryCollection({ + id: `virtual-props-filter-live`, + query: (q) => + q.from({ item: source }).where(({ item }) => eq(item.$synced, true)), + }) + + const liveChanges: Array> = [] + const liveSub = syncedOnly.subscribeChanges( + (events) => liveChanges.push(...events), + { includeInitialState: true }, + ) + + await syncedOnly.stateWhenReady() + await waitForChanges() + + expect(liveChanges.some((change) => change.value.id === `synced-1`)).toBe( + true, + ) + + source.insert({ id: `optimistic-1`, value: `pending` }) + await waitForChanges() + + expect( + liveChanges.some((change) => change.value.id === `optimistic-1`), + ).toBe(false) + + liveSub.unsubscribe() + await source.cleanup() + await syncedOnly.cleanup() + }) + + it(`should aggregate $synced and $origin for grouped rows`, async () => { + let syncFns: + | { + begin: () => void + write: (change: { + type: `insert` + value: { id: string; group: string } + }) => void + commit: () => void + } + | undefined + + const source = createCollection<{ id: string; group: string }, string>({ + id: `virtual-props-aggregate-source`, + getKey: (item) => item.id, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncFns = { + begin, + write, + commit, + } + markReady() + }, + }, + onInsert: async () => { + await waitForChanges() + }, + }) + + const grouped = createLiveQueryCollection({ + id: `virtual-props-aggregate-live`, + query: (q) => + q + .from({ item: source }) + .groupBy(({ item }) => item.group) + .select(({ item }) => ({ + group: item.group, + total: count(item.id), + })), + }) + + await grouped.stateWhenReady() + + if (!syncFns) { + throw new Error(`Sync not ready`) + } + syncFns.begin() + syncFns.write({ type: `insert`, value: { id: `remote-1`, group: `g1` } }) + syncFns.commit() + + await waitForChanges() + + source.insert({ id: `local-1`, group: `g1` }) + await waitForChanges() + + const groupRow = grouped.toArray.find((row) => row.group === `g1`) + expect(groupRow).toBeDefined() + expect(groupRow!.$synced).toBe(false) + expect(groupRow!.$origin).toBe(`local`) + expect(groupRow!.$collectionId).toBe(`virtual-props-aggregate-source`) + + await source.cleanup() + await grouped.cleanup() + }) + + it(`should mark local-only collections as synced with local origin`, async () => { + const collection = createCollection<{ id: string; value: string }, string>( + localOnlyCollectionOptions({ + id: `virtual-props-local-only`, + getKey: (item: { id: string; value: string }) => item.id, + }), + ) + + const changes: Array< + ChangeMessage> + > = [] + const subscription = collection.subscribeChanges( + ( + events: Array< + ChangeMessage< + OutputWithVirtual<{ id: string; value: string }, string> + > + >, + ) => changes.push(...events), + { includeInitialState: false }, + ) + + collection.insert({ id: `local-1`, value: `local` }) + await waitForChanges() + + const insertChange = changes.find((change) => change.key === `local-1`) + expect(insertChange).toBeDefined() + + const value = insertChange!.value as Record + expect(value.$synced).toBe(true) + expect(value.$origin).toBe(`local`) + + subscription.unsubscribe() + await collection.cleanup() + }) +}) diff --git a/packages/db/tests/collection-truncate.test.ts b/packages/db/tests/collection-truncate.test.ts index 1e12b0416..b4f244258 100644 --- a/packages/db/tests/collection-truncate.test.ts +++ b/packages/db/tests/collection-truncate.test.ts @@ -1,7 +1,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createCollection } from '../src/collection/index.js' +import { stripVirtualProps } from './utils' import type { LoadSubsetOptions, SyncConfig } from '../src/types' +const getStateValue = ( + collection: { state: Map }, + key: TKey, +) => stripVirtualProps(collection.state.get(key)) + +const stripChange = (change: any) => ({ + ...change, + value: stripVirtualProps(change?.value), + previousValue: stripVirtualProps(change?.previousValue), +}) + +const stripChanges = (changes: Array) => changes.map(stripChange) + describe(`Collection truncate operations`, () => { beforeEach(() => { vi.useFakeTimers() @@ -56,7 +70,7 @@ describe(`Collection truncate operations`, () => { const tx = collection.insert({ id: 3, value: `new-item` }) expect(changeEvents.length).toBe(1) - expect(changeEvents[0]).toEqual({ + expect(stripChange(changeEvents[0])).toEqual({ type: `insert`, key: 3, value: { id: 3, value: `new-item` }, @@ -75,9 +89,9 @@ describe(`Collection truncate operations`, () => { // Verify final state includes all items expect(collection.state.size).toBe(3) - expect(collection.state.get(1)).toEqual({ id: 1, value: `initial-1` }) - expect(collection.state.get(2)).toEqual({ id: 2, value: `initial-2` }) - expect(collection.state.get(3)).toEqual({ id: 3, value: `new-item` }) + expect(getStateValue(collection, 1)).toEqual({ id: 1, value: `initial-1` }) + expect(getStateValue(collection, 2)).toEqual({ id: 2, value: `initial-2` }) + expect(getStateValue(collection, 3)).toEqual({ id: 3, value: `new-item` }) // Verify only one insert event for the optimistic item const key3Inserts = changeEvents.filter( @@ -138,7 +152,7 @@ describe(`Collection truncate operations`, () => { expect(collection.state.size).toBe(2) expect(collection.state.has(1)).toBe(true) expect(collection.state.has(2)).toBe(true) - expect(collection.state.get(2)).toEqual({ id: 2, value: `new-item` }) + expect(getStateValue(collection, 2)).toEqual({ id: 2, value: `new-item` }) }) it(`should handle truncate on empty collection followed by mutation sync`, async () => { @@ -178,7 +192,7 @@ describe(`Collection truncate operations`, () => { const tx = collection.insert({ id: 1, value: `user-item` }) expect(changeEvents.length).toBe(1) - expect(changeEvents[0]).toEqual({ + expect(stripChange(changeEvents[0])).toEqual({ type: `insert`, key: 1, value: { id: 1, value: `user-item` }, @@ -194,7 +208,7 @@ describe(`Collection truncate operations`, () => { await tx.isPersisted.promise // Item should be present in final state - expect(collection.state.get(1)).toEqual({ id: 1, value: `user-item` }) + expect(getStateValue(collection, 1)).toEqual({ id: 1, value: `user-item` }) // Should not have duplicate insert events const insertCount = changeEvents.filter( @@ -238,7 +252,7 @@ describe(`Collection truncate operations`, () => { expect(collection.state.size).toBe(1) expect(changeEvents.length).toBe(1) - expect(changeEvents[0]).toEqual({ + expect(stripChange(changeEvents[0])).toEqual({ type: `insert`, key: 1, value: { id: 1, value: `optimistic-only` }, @@ -256,7 +270,7 @@ describe(`Collection truncate operations`, () => { // Should emit delete event for the optimistic item expect(deleteEvents.length).toBe(1) - expect(deleteEvents[0]).toEqual({ + expect(stripChange(deleteEvents[0])).toEqual({ type: `delete`, key: 1, value: { id: 1, value: `optimistic-only` }, @@ -264,7 +278,7 @@ describe(`Collection truncate operations`, () => { // Then re-insert the preserved optimistic item expect(insertEvents.length).toBe(1) - expect(insertEvents[0]).toEqual({ + expect(stripChange(insertEvents[0])).toEqual({ type: `insert`, key: 1, value: { id: 1, value: `optimistic-only` }, @@ -329,9 +343,12 @@ describe(`Collection truncate operations`, () => { expect(collection.state.size).toBe(2) expect(collection.state.has(1)).toBe(true) - expect(collection.state.get(1)).toEqual({ id: 1, value: `server-item` }) + expect(getStateValue(collection, 1)).toEqual({ + id: 1, + value: `server-item`, + }) expect(collection.state.has(2)).toBe(true) - expect(collection.state.get(2)).toEqual({ + expect(getStateValue(collection, 2)).toEqual({ id: 2, value: `late-optimistic`, }) @@ -400,7 +417,10 @@ describe(`Collection truncate operations`, () => { expect(collection.state.size).toBe(2) expect(collection.state.has(1)).toBe(true) expect(collection.state.has(2)).toBe(true) - expect(collection.state.get(2)).toEqual({ id: 2, value: `optimistic-item` }) + expect(getStateValue(collection, 2)).toEqual({ + id: 2, + value: `optimistic-item`, + }) }) it(`should preserve optimistic delete when transaction still active during truncate`, async () => { @@ -490,7 +510,10 @@ describe(`Collection truncate operations`, () => { collection.subscribeChanges((changes) => changeEvents.push(...changes)) await collection.stateWhenReady() - expect(collection.state.get(1)).toEqual({ id: 1, value: `server-value-1` }) + expect(getStateValue(collection, 1)).toEqual({ + id: 1, + value: `server-value-1`, + }) changeEvents.length = 0 // Optimistically update item 1 (handler stays pending) @@ -498,7 +521,7 @@ describe(`Collection truncate operations`, () => { draft.value = `optimistic-value` }) - expect(collection.state.get(1)).toEqual({ + expect(getStateValue(collection, 1)).toEqual({ id: 1, value: `optimistic-value`, }) @@ -515,7 +538,7 @@ describe(`Collection truncate operations`, () => { syncOps!.commit() // Optimistic value should win (client intent preserved) - expect(collection.state.get(1)).toEqual({ + expect(getStateValue(collection, 1)).toEqual({ id: 1, value: `optimistic-value`, }) @@ -626,7 +649,7 @@ describe(`Collection truncate operations`, () => { draft.value = `value-1` }) - expect(collection.state.get(1)).toEqual({ id: 1, value: `value-1` }) + expect(getStateValue(collection, 1)).toEqual({ id: 1, value: `value-1` }) // Truncate is called (snapshot captures value-1) syncOps!.begin() @@ -637,14 +660,14 @@ describe(`Collection truncate operations`, () => { draft.value = `value-2` }) - expect(collection.state.get(1)).toEqual({ id: 1, value: `value-2` }) + expect(getStateValue(collection, 1)).toEqual({ id: 1, value: `value-2` }) // Now commit the truncate syncOps!.write({ type: `insert`, value: { id: 1, value: `initial` } }) syncOps!.commit() // Should show value-2 (newest intent wins) - expect(collection.state.get(1)).toEqual({ id: 1, value: `value-2` }) + expect(getStateValue(collection, 1)).toEqual({ id: 1, value: `value-2` }) // Clean up onUpdateResolvers.forEach((r) => r()) @@ -704,7 +727,7 @@ describe(`Collection truncate operations`, () => { // Item 2 should still be present (preserved from snapshot) expect(collection.state.size).toBe(2) expect(collection.state.has(2)).toBe(true) - expect(collection.state.get(2)).toEqual({ id: 2, value: `optimistic` }) + expect(getStateValue(collection, 2)).toEqual({ id: 2, value: `optimistic` }) }) it(`should buffer subscription changes during truncate until loadSubset refetch completes`, async () => { @@ -780,7 +803,7 @@ describe(`Collection truncate operations`, () => { await vi.waitFor(() => expect(changeEvents.length).toBe(2)) // Verify initial data arrived - expect(changeEvents).toEqual([ + expect(stripChanges(changeEvents)).toEqual([ { type: `insert`, key: 1, value: { id: 1, value: `refetched-1` } }, { type: `insert`, key: 2, value: { id: 2, value: `refetched-2` } }, ]) @@ -820,8 +843,14 @@ describe(`Collection truncate operations`, () => { // Verify final state is correct expect(collection.state.size).toBe(2) - expect(collection.state.get(1)).toEqual({ id: 1, value: `refetched-1` }) - expect(collection.state.get(2)).toEqual({ id: 2, value: `refetched-2` }) + expect(getStateValue(collection, 1)).toEqual({ + id: 1, + value: `refetched-1`, + }) + expect(getStateValue(collection, 2)).toEqual({ + id: 2, + value: `refetched-2`, + }) subscription.unsubscribe() }) @@ -1125,8 +1154,14 @@ describe(`Collection truncate operations`, () => { // Verify collection state is correct expect(collection.state.size).toBe(2) - expect(collection.state.get(1)).toEqual({ id: 1, value: `sync-item-1` }) - expect(collection.state.get(2)).toEqual({ id: 2, value: `sync-item-2` }) + expect(getStateValue(collection, 1)).toEqual({ + id: 1, + value: `sync-item-1`, + }) + expect(getStateValue(collection, 2)).toEqual({ + id: 2, + value: `sync-item-2`, + }) subscription.unsubscribe() }) diff --git a/packages/db/tests/collection.test-d.ts b/packages/db/tests/collection.test-d.ts index 832e3b3cd..32edff2f8 100644 --- a/packages/db/tests/collection.test-d.ts +++ b/packages/db/tests/collection.test-d.ts @@ -1,6 +1,7 @@ import { assertType, describe, expectTypeOf, it } from 'vitest' import { z } from 'zod' import { createCollection } from '../src/collection/index.js' +import type { OutputWithVirtual } from './utils' import type { OperationConfig } from '../src/types' import type { StandardSchemaV1 } from '@standard-schema/spec' @@ -59,7 +60,6 @@ describe(`Collection type resolution tests`, () => { type SchemaType = StandardSchemaV1.InferOutput type ItemOf = T extends Array ? U : T - it(`should use explicit type when provided without schema`, () => { const _collection = createCollection({ getKey: (item) => { @@ -69,7 +69,12 @@ describe(`Collection type resolution tests`, () => { sync: { sync: () => {} }, }) - expectTypeOf(_collection.toArray).toEqualTypeOf>() + expectTypeOf(_collection.toArray).toEqualTypeOf< + Array> + >() + expectTypeOf(_collection.get(`test`)).toEqualTypeOf< + OutputWithVirtual | undefined + >() type Key = Parameters[0] expectTypeOf().toEqualTypeOf() @@ -88,7 +93,12 @@ describe(`Collection type resolution tests`, () => { schema: testSchema, }) - expectTypeOf(_collection.toArray).toEqualTypeOf>() + expectTypeOf(_collection.toArray).toEqualTypeOf< + Array> + >() + expectTypeOf(_collection.get(`test`)).toEqualTypeOf< + OutputWithVirtual | undefined + >() type Key = Parameters[0] expectTypeOf().toEqualTypeOf() @@ -214,7 +224,9 @@ describe(`Schema Input/Output Type Distinction`, () => { >() // Collection items should be ExpectedOutputType - expectTypeOf(collection.toArray).toEqualTypeOf>() + expectTypeOf(collection.toArray).toEqualTypeOf< + Array> + >() }) it(`should handle schema with transformations correctly for insert`, () => { @@ -256,7 +268,9 @@ describe(`Schema Input/Output Type Distinction`, () => { >() // Collection items should be ExpectedOutputType - expectTypeOf(collection.toArray).toEqualTypeOf>() + expectTypeOf(collection.toArray).toEqualTypeOf< + Array> + >() }) it(`should handle schema with default values correctly for update method`, () => { @@ -302,7 +316,9 @@ describe(`Schema Input/Output Type Distinction`, () => { }) // Collection items should be ExpectedOutputType - expectTypeOf(collection.toArray).toEqualTypeOf>() + expectTypeOf(collection.toArray).toEqualTypeOf< + Array> + >() }) it(`should handle schema with transformations correctly for update method`, () => { @@ -348,7 +364,9 @@ describe(`Schema Input/Output Type Distinction`, () => { }) // Collection items should be ExpectedOutputType - expectTypeOf(collection.toArray).toEqualTypeOf>() + expectTypeOf(collection.toArray).toEqualTypeOf< + Array> + >() }) }) diff --git a/packages/db/tests/collection.test.ts b/packages/db/tests/collection.test.ts index ecb60361d..bc3671611 100644 --- a/packages/db/tests/collection.test.ts +++ b/packages/db/tests/collection.test.ts @@ -14,10 +14,27 @@ import { createTransaction } from '../src/transactions' import { flushPromises, mockSyncCollectionOptionsNoInitialState, + stripVirtualProps, withExpectedRejection, } from './utils' import type { ChangeMessage, MutationFn, PendingMutation } from '../src/types' +const getStateValue = ( + collection: { state: Map }, + key: TKey, +) => stripVirtualProps(collection.state.get(key)) + +const getStateEntries = < + T extends object, + TKey extends string | number, +>(collection: { + state: Map +}) => + Array.from(collection.state.entries()).map(([key, value]) => [ + key, + stripVirtualProps(value), + ]) + describe(`Collection`, () => { it(`should throw if there's no sync config`, () => { // @ts-expect-error we're testing for throwing when there's no config passed in @@ -47,9 +64,11 @@ describe(`Collection`, () => { await collection.stateWhenReady() // Verify initial state - expect(Array.from(collection.state.values())).toEqual([ - { value: `initial value` }, - ]) + expect( + Array.from(collection.state.values()).map((value) => + stripVirtualProps(value), + ), + ).toEqual([{ value: `initial value` }]) // Verify that insert throws an error expect(() => { @@ -137,7 +156,11 @@ describe(`Collection`, () => { // Now the data should be visible const expectedData = [{ name: `Alice` }, { name: `Bob` }] - expect(Array.from(collection.state.values())).toEqual(expectedData) + expect( + Array.from(collection.state.values()).map((value) => + stripVirtualProps(value), + ), + ).toEqual(expectedData) }, }, }) @@ -213,9 +236,12 @@ describe(`Collection`, () => { const insertedKey = tx.mutations[0].key as string // The merged value should immediately contain the new insert - expect(collection.state).toEqual( - new Map([[insertedKey, { id: 1, value: `bar` }]]), - ) + expect( + Array.from(collection.state.entries()).map(([key, value]) => [ + key, + stripVirtualProps(value), + ]), + ).toEqual([[insertedKey, { id: 1, value: `bar` }]]) // check there's a transaction in peristing state expect( @@ -264,15 +290,15 @@ describe(`Collection`, () => { // after mutationFn returns, check that the transaction is cleaned up, // optimistic update is gone & synced data & combined state are all updated. expect(collection._state.transactions.size).toEqual(0) // Transaction should be cleaned up - expect(collection.state).toEqual( - new Map([[insertedKey, { id: 1, value: `bar` }]]), - ) + expect(getStateEntries(collection)).toEqual([ + [insertedKey, { id: 1, value: `bar` }], + ]) expect(collection._state.optimisticUpserts.size).toEqual(0) // Test insert with provided key const tx2 = createTransaction({ mutationFn }) tx2.mutate(() => collection.insert({ id: 2, value: `baz` })) - expect(collection.state.get(2)).toEqual({ + expect(getStateValue(collection, 2)).toEqual({ id: 2, value: `baz`, }) @@ -287,9 +313,13 @@ describe(`Collection`, () => { tx3.mutate(() => collection.insert(bulkData)) const keys = Array.from(collection.state.keys()) // @ts-expect-error possibly undefined is ok in test - expect(collection.state.get(keys[2])).toEqual(bulkData[0]) + expect(stripVirtualProps(collection.state.get(keys[2]))).toEqual( + bulkData[0], + ) // @ts-expect-error possibly undefined is ok in test - expect(collection.state.get(keys[3])).toEqual(bulkData[1]) + expect(stripVirtualProps(collection.state.get(keys[3]))).toEqual( + bulkData[1], + ) await tx3.isPersisted.promise const tx4 = createTransaction({ mutationFn }) @@ -302,7 +332,10 @@ describe(`Collection`, () => { ) // The merged value should contain the update. - expect(collection.state.get(insertedKey)).toEqual({ id: 1, value: `bar2` }) + expect(getStateValue(collection, insertedKey)).toEqual({ + id: 1, + value: `bar2`, + }) await tx4.isPersisted.promise const tx5 = createTransaction({ mutationFn }) @@ -319,7 +352,7 @@ describe(`Collection`, () => { ) // The merged value should contain the update - expect(collection.state.get(insertedKey)).toEqual({ + expect(getStateValue(collection, insertedKey)).toEqual({ id: 1, value: `bar3`, newProp: `new value`, @@ -350,7 +383,7 @@ describe(`Collection`, () => { }) // The merged value should contain the update - expect(collection.state.get(insertedKey)).toEqual({ + expect(getStateValue(collection, insertedKey)).toEqual({ id: 1, value: `bar3`, newProp: `new value`, @@ -376,13 +409,13 @@ describe(`Collection`, () => { // Check bulk updates // @ts-expect-error possibly undefined is ok in test - expect(collection.state.get(keys[2])).toEqual({ + expect(getStateValue(collection, keys[2])).toEqual({ boolean: true, id: 3, value: `item1-updated`, }) // @ts-expect-error possibly undefined is ok in test - expect(collection.state.get(keys[3])).toEqual({ + expect(getStateValue(collection, keys[3])).toEqual({ boolean: true, id: 4, value: `item2-updated`, @@ -457,7 +490,9 @@ describe(`Collection`, () => { // This update is ignored because the optimistic update overrides it. { type: `insert`, changes: { id: 2, bar: `value2` } }, ]) - expect(collection.state).toEqual(new Map([[1, { id: 1, value: `bar` }]])) + expect(getStateEntries(collection)).toEqual([ + [1, { id: 1, value: `bar` }], + ]) // Remove it so we don't have to assert against it below emitter.emit(`update`, [{ changes: { id: 2 }, type: `delete` }]) @@ -476,7 +511,7 @@ describe(`Collection`, () => { ) // The merged value should immediately contain the new insert - expect(collection.state).toEqual(new Map([[1, { id: 1, value: `bar` }]])) + expect(getStateEntries(collection)).toEqual([[1, { id: 1, value: `bar` }]]) // check there's a transaction in peristing state expect( @@ -498,7 +533,7 @@ describe(`Collection`, () => { await tx1.isPersisted.promise - expect(collection.state).toEqual(new Map([[1, { id: 1, value: `bar` }]])) + expect(getStateEntries(collection)).toEqual([[1, { id: 1, value: `bar` }]]) }) it(`should throw errors when deleting items not in the collection`, () => { @@ -609,8 +644,8 @@ describe(`Collection`, () => { }).not.toThrow() // Verify both items were inserted - expect(collection.state.get(2)).toEqual({ id: 2, value: `first` }) - expect(collection.state.get(3)).toEqual({ id: 3, value: `second` }) + expect(getStateValue(collection, 2)).toEqual({ id: 2, value: `first` }) + expect(getStateValue(collection, 3)).toEqual({ id: 3, value: `second` }) }) it(`should throw InvalidKeyError when getKey returns null`, async () => { @@ -990,7 +1025,7 @@ describe(`Collection`, () => { // Now the item should appear after server confirmation expect(collection.state.has(2)).toBe(true) - expect(collection.state.get(2)).toEqual({ + expect(getStateValue(collection, 2)).toEqual({ id: 2, value: `non-optimistic insert`, }) @@ -1005,7 +1040,7 @@ describe(`Collection`, () => { ) // The original value should still be there immediately - expect(collection.state.get(1)?.value).toBe(`initial value`) + expect(getStateValue(collection, 1)?.value).toBe(`initial value`) expect(collection._state.optimisticUpserts.has(1)).toBe(false) // Now resolve the update mutation and wait for completion @@ -1013,7 +1048,7 @@ describe(`Collection`, () => { await nonOptimisticUpdateTx.isPersisted.promise // Now the update should be reflected - expect(collection.state.get(1)?.value).toBe(`non-optimistic update`) + expect(getStateValue(collection, 1)?.value).toBe(`non-optimistic update`) // Test non-optimistic delete const nonOptimisticDeleteTx = collection.delete(2, { optimistic: false }) @@ -1082,7 +1117,7 @@ describe(`Collection`, () => { // The item should appear immediately expect(collection.state.has(2)).toBe(true) expect(collection._state.optimisticUpserts.has(2)).toBe(true) - expect(collection.state.get(2)).toEqual({ + expect(getStateValue(collection, 2)).toEqual({ id: 2, value: `default optimistic`, }) @@ -1098,7 +1133,7 @@ describe(`Collection`, () => { // The item should appear immediately expect(collection.state.has(3)).toBe(true) expect(collection._state.optimisticUpserts.has(3)).toBe(true) - expect(collection.state.get(3)).toEqual({ + expect(getStateValue(collection, 3)).toEqual({ id: 3, value: `explicit optimistic`, }) @@ -1115,7 +1150,7 @@ describe(`Collection`, () => { ) // The update should be reflected immediately - expect(collection.state.get(1)?.value).toBe(`optimistic update`) + expect(getStateValue(collection, 1)?.value).toBe(`optimistic update`) expect(collection._state.optimisticUpserts.has(1)).toBe(true) await optimisticUpdateTx.isPersisted.promise @@ -1196,20 +1231,20 @@ describe(`Collection`, () => { const tx1 = collection.update(1, (draft) => { draft.checked = true }) - expect(collection.state.get(1)?.checked).toBe(true) + expect(getStateValue(collection, 1)?.checked).toBe(true) const initialEventCount = changeEvents.length // Step 2: Second click immediately (before first completes) const tx2 = collection.update(1, (draft) => { draft.checked = false }) - expect(collection.state.get(1)?.checked).toBe(false) + expect(getStateValue(collection, 1)?.checked).toBe(false) // Step 3: Third click immediately (before others complete) const tx3 = collection.update(1, (draft) => { draft.checked = true }) - expect(collection.state.get(1)?.checked).toBe(true) + expect(getStateValue(collection, 1)?.checked).toBe(true) // CRITICAL TEST: Verify events are still being emitted for rapid user actions // Before the fix, these would be batched and UI would freeze @@ -1232,7 +1267,7 @@ describe(`Collection`, () => { // CRITICAL: Verify that even after sync/batching starts, user actions still emit events expect(changeEvents.length).toBeGreaterThan(eventCountBeforeRapidClicks) - expect(collection.state.get(1)?.checked).toBe(true) // Last action should win + expect(getStateValue(collection, 1)?.checked).toBe(true) // Last action should win // Clean up remaining transactions for (let i = 1; i < txResolvers.length; i++) { @@ -1278,8 +1313,14 @@ describe(`Collection`, () => { // Verify initial state expect(collection.state.size).toBe(2) - expect(collection.state.get(1)).toEqual({ id: 1, value: `initial value 1` }) - expect(collection.state.get(2)).toEqual({ id: 2, value: `initial value 2` }) + expect(getStateValue(collection, 1)).toEqual({ + id: 1, + value: `initial value 1`, + }) + expect(getStateValue(collection, 2)).toEqual({ + id: 2, + value: `initial value 2`, + }) // Test truncate operation const { begin, truncate, commit } = testSyncFunctions @@ -1321,7 +1362,10 @@ describe(`Collection`, () => { // Verify initial state expect(collection.state.size).toBe(1) - expect(collection.state.get(1)).toEqual({ id: 1, value: `initial value` }) + expect(getStateValue(collection, 1)).toEqual({ + id: 1, + value: `initial value`, + }) // Test truncate operation with additional operations in the same transaction const { begin, write, truncate, commit } = testSyncFunctions @@ -1351,7 +1395,7 @@ describe(`Collection`, () => { // Verify only post-truncate operations are kept expect(collection.state.size).toBe(1) - expect(collection.state.get(3)).toEqual({ + expect(getStateValue(collection, 3)).toEqual({ id: 3, value: `should not be cleared`, }) @@ -1429,7 +1473,7 @@ describe(`Collection`, () => { // we should immediately see the optimistic state expect(collection.state.size).toBe(3) - expect(collection.state.get(3)?.name).toBe(`three`) + expect(getStateValue(collection, 3)?.name).toBe(`three`) // we now reject the sync, this should trigger a rollback of the open transaction // and the optimistic state should be removed @@ -1489,11 +1533,11 @@ describe(`Collection`, () => { // Data should be visible even though not ready expect(collection.status).toBe(`loading`) expect(collection.size).toBe(2) - expect(collection.state.get(1)).toEqual({ + expect(getStateValue(collection, 1)).toEqual({ id: 1, value: `first batch item 1`, }) - expect(collection.state.get(2)).toEqual({ + expect(getStateValue(collection, 2)).toEqual({ id: 2, value: `first batch item 2`, }) @@ -1510,11 +1554,11 @@ describe(`Collection`, () => { // More data should be visible expect(collection.status).toBe(`loading`) expect(collection.size).toBe(3) - expect(collection.state.get(1)).toEqual({ + expect(getStateValue(collection, 1)).toEqual({ id: 1, value: `first batch item 1 updated`, }) - expect(collection.state.get(3)).toEqual({ + expect(getStateValue(collection, 3)).toEqual({ id: 3, value: `second batch item 1`, }) @@ -1528,8 +1572,8 @@ describe(`Collection`, () => { // Updates should be reflected expect(collection.status).toBe(`loading`) expect(collection.size).toBe(3) // Deleted 2, added 4 - expect(collection.state.get(2)).toBeUndefined() - expect(collection.state.get(4)).toEqual({ + expect(getStateValue(collection, 2)).toBeUndefined() + expect(getStateValue(collection, 4)).toEqual({ id: 4, value: `third batch item 1`, }) @@ -1577,9 +1621,9 @@ describe(`Collection`, () => { // Verify data was inserted expect(collection.size).toBe(3) - expect(collection.state.get(1)).toEqual({ id: 1, value: `item 1` }) - expect(collection.state.get(2)).toEqual({ id: 2, value: `item 2` }) - expect(collection.state.get(3)).toEqual({ id: 3, value: `item 3` }) + expect(getStateValue(collection, 1)).toEqual({ id: 1, value: `item 1` }) + expect(getStateValue(collection, 2)).toEqual({ id: 2, value: `item 2` }) + expect(getStateValue(collection, 3)).toEqual({ id: 3, value: `item 3` }) // Delete a row by passing only the key (no value) begin() @@ -1588,9 +1632,9 @@ describe(`Collection`, () => { // Verify the row is gone expect(collection.size).toBe(2) - expect(collection.state.get(1)).toEqual({ id: 1, value: `item 1` }) - expect(collection.state.get(2)).toBeUndefined() - expect(collection.state.get(3)).toEqual({ id: 3, value: `item 3` }) + expect(getStateValue(collection, 1)).toEqual({ id: 1, value: `item 1` }) + expect(getStateValue(collection, 2)).toBeUndefined() + expect(getStateValue(collection, 3)).toEqual({ id: 3, value: `item 3` }) // Delete another row by key only begin() @@ -1599,9 +1643,9 @@ describe(`Collection`, () => { // Verify both rows are gone expect(collection.size).toBe(1) - expect(collection.state.get(1)).toBeUndefined() - expect(collection.state.get(2)).toBeUndefined() - expect(collection.state.get(3)).toEqual({ id: 3, value: `item 3` }) + expect(getStateValue(collection, 1)).toBeUndefined() + expect(getStateValue(collection, 2)).toBeUndefined() + expect(getStateValue(collection, 3)).toEqual({ id: 3, value: `item 3` }) // Mark as ready markReady() @@ -1638,9 +1682,12 @@ describe(`Collection`, () => { // Verify data was inserted expect(collection.size).toBe(3) - expect(collection.state.get(`a`)).toEqual({ id: `a`, name: `Alice` }) - expect(collection.state.get(`b`)).toEqual({ id: `b`, name: `Bob` }) - expect(collection.state.get(`c`)).toEqual({ id: `c`, name: `Charlie` }) + expect(getStateValue(collection, `a`)).toEqual({ id: `a`, name: `Alice` }) + expect(getStateValue(collection, `b`)).toEqual({ id: `b`, name: `Bob` }) + expect(getStateValue(collection, `c`)).toEqual({ + id: `c`, + name: `Charlie`, + }) // Delete by key only begin() @@ -1649,9 +1696,12 @@ describe(`Collection`, () => { // Verify the row is gone expect(collection.size).toBe(2) - expect(collection.state.get(`a`)).toEqual({ id: `a`, name: `Alice` }) - expect(collection.state.get(`b`)).toBeUndefined() - expect(collection.state.get(`c`)).toEqual({ id: `c`, name: `Charlie` }) + expect(getStateValue(collection, `a`)).toEqual({ id: `a`, name: `Alice` }) + expect(getStateValue(collection, `b`)).toBeUndefined() + expect(getStateValue(collection, `c`)).toEqual({ + id: `c`, + name: `Charlie`, + }) markReady() expect(collection.status).toBe(`ready`) diff --git a/packages/db/tests/local-only.test-d.ts b/packages/db/tests/local-only.test-d.ts index 471a266b3..85200384e 100644 --- a/packages/db/tests/local-only.test-d.ts +++ b/packages/db/tests/local-only.test-d.ts @@ -2,6 +2,7 @@ import { describe, expectTypeOf, it } from 'vitest' import { z } from 'zod' import { createCollection } from '../src/index' import { localOnlyCollectionOptions } from '../src/local-only' +import type { OutputWithVirtual } from './utils' interface TestItem extends Record { id: number @@ -9,6 +10,9 @@ interface TestItem extends Record { completed?: boolean } +type TestItemWithVirtual = OutputWithVirtual +type TestItemWithVirtualStringKey = OutputWithVirtual + type ItemOf = T extends Array ? U : T describe(`LocalOnly Collection Types`, () => { @@ -62,8 +66,10 @@ describe(`LocalOnly Collection Types`, () => { expectTypeOf(collection.insert).toBeFunction() expectTypeOf(collection.update).toBeFunction() expectTypeOf(collection.delete).toBeFunction() - expectTypeOf(collection.get).returns.toEqualTypeOf() - expectTypeOf(collection.toArray).toEqualTypeOf>() + expectTypeOf(collection.get).returns.toEqualTypeOf< + TestItemWithVirtual | undefined + >() + expectTypeOf(collection.toArray).toEqualTypeOf>() // Test insert parameter type type InsertParam = Parameters[0] @@ -91,8 +97,10 @@ describe(`LocalOnly Collection Types`, () => { expectTypeOf(collection.insert).toBeFunction() expectTypeOf(collection.update).toBeFunction() expectTypeOf(collection.delete).toBeFunction() - expectTypeOf(collection.get).returns.toEqualTypeOf() - expectTypeOf(collection.toArray).toEqualTypeOf>() + expectTypeOf(collection.get).returns.toEqualTypeOf< + TestItemWithVirtual | undefined + >() + expectTypeOf(collection.toArray).toEqualTypeOf>() // Test insert parameter type type InsertParam2 = Parameters[0] @@ -118,8 +126,10 @@ describe(`LocalOnly Collection Types`, () => { expectTypeOf(collection.insert).toBeFunction() expectTypeOf(collection.update).toBeFunction() expectTypeOf(collection.delete).toBeFunction() - expectTypeOf(collection.get).returns.toEqualTypeOf() - expectTypeOf(collection.toArray).toEqualTypeOf>() + expectTypeOf(collection.get).returns.toEqualTypeOf< + TestItemWithVirtual | undefined + >() + expectTypeOf(collection.toArray).toEqualTypeOf>() }) it(`should infer key type from getKey function`, () => { @@ -135,8 +145,12 @@ describe(`LocalOnly Collection Types`, () => { expectTypeOf(collection.insert).toBeFunction() expectTypeOf(collection.update).toBeFunction() expectTypeOf(collection.delete).toBeFunction() - expectTypeOf(collection.get).returns.toEqualTypeOf() - expectTypeOf(collection.toArray).toEqualTypeOf>() + expectTypeOf(collection.get).returns.toEqualTypeOf< + TestItemWithVirtualStringKey | undefined + >() + expectTypeOf(collection.toArray).toEqualTypeOf< + Array + >() expectTypeOf(options.getKey).toBeFunction() }) @@ -193,7 +207,9 @@ describe(`LocalOnly Collection Types`, () => { }) // Test that the collection has the correct inferred type from schema - expectTypeOf(collection.toArray).toEqualTypeOf>() + expectTypeOf(collection.toArray).toEqualTypeOf< + Array> + >() }) it(`should work with schema and infer correct types when nested in createCollection`, () => { @@ -250,6 +266,8 @@ describe(`LocalOnly Collection Types`, () => { }) // Test that the collection has the correct inferred type from schema - expectTypeOf(collection.toArray).toEqualTypeOf>() + expectTypeOf(collection.toArray).toEqualTypeOf< + Array> + >() }) }) diff --git a/packages/db/tests/local-only.test.ts b/packages/db/tests/local-only.test.ts index 1eb8ac0b5..5146c7957 100644 --- a/packages/db/tests/local-only.test.ts +++ b/packages/db/tests/local-only.test.ts @@ -3,6 +3,7 @@ import { createCollection, liveQueryCollectionOptions } from '../src/index' import { sum } from '../src/query/builder/functions' import { localOnlyCollectionOptions } from '../src/local-only' import { createTransaction } from '../src/transactions' +import { stripVirtualProps } from './utils' import type { LocalOnlyCollectionUtils } from '../src/local-only' import type { Collection } from '../src/index' @@ -37,7 +38,10 @@ describe(`LocalOnly Collection`, () => { // The item should be immediately available in the collection expect(collection.has(1)).toBe(true) - expect(collection.get(1)).toEqual({ id: 1, name: `Test Item` }) + expect(stripVirtualProps(collection.get(1))).toEqual({ + id: 1, + name: `Test Item`, + }) expect(collection.size).toBe(1) }) @@ -51,9 +55,18 @@ describe(`LocalOnly Collection`, () => { // All items should be immediately available expect(collection.size).toBe(3) - expect(collection.get(1)).toEqual({ id: 1, name: `Item 1` }) - expect(collection.get(2)).toEqual({ id: 2, name: `Item 2` }) - expect(collection.get(3)).toEqual({ id: 3, name: `Item 3` }) + expect(stripVirtualProps(collection.get(1))).toEqual({ + id: 1, + name: `Item 1`, + }) + expect(stripVirtualProps(collection.get(2))).toEqual({ + id: 2, + name: `Item 2`, + }) + expect(stripVirtualProps(collection.get(3))).toEqual({ + id: 3, + name: `Item 3`, + }) }) it(`should handle update operations optimistically`, () => { @@ -67,7 +80,7 @@ describe(`LocalOnly Collection`, () => { }) // The update should be immediately reflected - expect(collection.get(1)).toEqual({ + expect(stripVirtualProps(collection.get(1))).toEqual({ id: 1, name: `Updated Item`, completed: true, @@ -112,13 +125,16 @@ describe(`LocalOnly Collection`, () => { // Check final state expect(collection.size).toBe(2) - expect(collection.get(1)).toEqual({ + expect(stripVirtualProps(collection.get(1))).toEqual({ id: 1, name: `Item 1`, completed: true, }) expect(collection.has(2)).toBe(false) - expect(collection.get(3)).toEqual({ id: 3, name: `Item 3` }) + expect(stripVirtualProps(collection.get(3))).toEqual({ + id: 3, + name: `Item 3`, + }) }) it(`should support change subscriptions`, () => { @@ -132,11 +148,17 @@ describe(`LocalOnly Collection`, () => { // The change handler should have been called expect(changeHandler).toHaveBeenCalledTimes(1) - expect(changeHandler).toHaveBeenCalledWith([ + const [changes] = changeHandler.mock.calls[0] as [Array] + const normalizedChanges = changes.map((change) => ({ + ...change, + value: stripVirtualProps(change.value), + })) + expect(normalizedChanges).toEqual([ { type: `insert`, key: 1, value: { id: 1, name: `Test Item` }, + previousValue: undefined, }, ]) @@ -152,7 +174,7 @@ describe(`LocalOnly Collection`, () => { { id: 2, name: `Item 2` }, ]) - const array = collection.toArray + const array = collection.toArray.map((row) => stripVirtualProps(row)) // Should contain all items expect(array).toHaveLength(3) @@ -173,7 +195,10 @@ describe(`LocalOnly Collection`, () => { ]) // Test entries - const entries = Array.from(collection.entries()) + const entries = Array.from(collection.entries()).map(([key, value]) => [ + key, + stripVirtualProps(value), + ]) expect(entries).toHaveLength(2) expect(entries).toEqual( expect.arrayContaining([ @@ -197,7 +222,10 @@ describe(`LocalOnly Collection`, () => { // The item should be available in the collection expect(collection.has(1)).toBe(true) - expect(collection.get(1)).toEqual({ id: 1, name: `Test Item` }) + expect(stripVirtualProps(collection.get(1))).toEqual({ + id: 1, + name: `Test Item`, + }) }) it(`should handle update operations optimistically`, () => { @@ -210,7 +238,10 @@ describe(`LocalOnly Collection`, () => { }) // The update should be reflected in the collection - expect(collection.get(1)).toEqual({ id: 1, name: `Updated Item` }) + expect(stripVirtualProps(collection.get(1))).toEqual({ + id: 1, + name: `Updated Item`, + }) }) it(`should handle delete operations optimistically`, () => { @@ -239,7 +270,10 @@ describe(`LocalOnly Collection`, () => { // Basic operations should still work testCollection.insert({ id: 1, name: `Test with Schema` }) - expect(testCollection.get(1)).toEqual({ id: 1, name: `Test with Schema` }) + expect(stripVirtualProps(testCollection.get(1))).toEqual({ + id: 1, + name: `Test with Schema`, + }) }) }) @@ -279,7 +313,10 @@ describe(`LocalOnly Collection`, () => { ) // Collection should still work normally - expect(testCollection.get(1)).toEqual({ id: 1, name: `Test Item` }) + expect(stripVirtualProps(testCollection.get(1))).toEqual({ + id: 1, + name: `Test Item`, + }) }) it(`should call custom onUpdate callback when provided`, () => { @@ -313,7 +350,10 @@ describe(`LocalOnly Collection`, () => { ) // Collection should still work normally - expect(testCollection.get(1)).toEqual({ id: 1, name: `Updated Item` }) + expect(stripVirtualProps(testCollection.get(1))).toEqual({ + id: 1, + name: `Updated Item`, + }) }) it(`should call custom onDelete callback when provided`, () => { @@ -385,9 +425,18 @@ describe(`LocalOnly Collection`, () => { // Collection should be populated with initial data expect(testCollection.size).toBe(3) - expect(testCollection.get(10)).toEqual({ id: 10, name: `Initial Item 1` }) - expect(testCollection.get(20)).toEqual({ id: 20, name: `Initial Item 2` }) - expect(testCollection.get(30)).toEqual({ id: 30, name: `Initial Item 3` }) + expect(stripVirtualProps(testCollection.get(10))).toEqual({ + id: 10, + name: `Initial Item 1`, + }) + expect(stripVirtualProps(testCollection.get(20))).toEqual({ + id: 20, + name: `Initial Item 2`, + }) + expect(stripVirtualProps(testCollection.get(30))).toEqual({ + id: 30, + name: `Initial Item 3`, + }) }) it(`should work with empty initial data array`, () => { @@ -428,14 +477,23 @@ describe(`LocalOnly Collection`, () => { // Should start with initial data expect(testCollection.size).toBe(1) - expect(testCollection.get(100)).toEqual({ id: 100, name: `Initial Item` }) + expect(stripVirtualProps(testCollection.get(100))).toEqual({ + id: 100, + name: `Initial Item`, + }) // Should be able to add more items testCollection.insert({ id: 200, name: `Added Item` }) expect(testCollection.size).toBe(2) - expect(testCollection.get(100)).toEqual({ id: 100, name: `Initial Item` }) - expect(testCollection.get(200)).toEqual({ id: 200, name: `Added Item` }) + expect(stripVirtualProps(testCollection.get(100))).toEqual({ + id: 100, + name: `Initial Item`, + }) + expect(stripVirtualProps(testCollection.get(200))).toEqual({ + id: 200, + name: `Added Item`, + }) }) }) @@ -476,7 +534,9 @@ describe(`LocalOnly Collection`, () => { await new Promise((resolve) => setTimeout(resolve, 10)) - expect(query.toArray).toEqual([{ totalNumber: 30 }]) + expect(query.toArray.map((row) => stripVirtualProps(row))).toEqual([ + { totalNumber: 30 }, + ]) }) }) @@ -505,8 +565,11 @@ describe(`LocalOnly Collection`, () => { tx.commit() // Items should still be in collection after commit - expect(collection.get(100)).toEqual({ id: 100, name: `Manual Tx Insert` }) - expect(collection.get(101)).toEqual({ + expect(stripVirtualProps(collection.get(100))).toEqual({ + id: 100, + name: `Manual Tx Insert`, + }) + expect(stripVirtualProps(collection.get(101))).toEqual({ id: 101, name: `Manual Tx Insert 2`, }) @@ -589,7 +652,10 @@ describe(`LocalOnly Collection`, () => { tx.commit() - expect(collection.get(500)).toEqual({ id: 500, name: `Before API` }) + expect(stripVirtualProps(collection.get(500))).toEqual({ + id: 500, + name: `Before API`, + }) }) it(`should work when called after API operations`, () => { @@ -609,7 +675,10 @@ describe(`LocalOnly Collection`, () => { tx.commit() - expect(collection.get(600)).toEqual({ id: 600, name: `After API` }) + expect(stripVirtualProps(collection.get(600))).toEqual({ + id: 600, + name: `After API`, + }) }) it(`should rollback mutations when transaction fails`, async () => { diff --git a/packages/db/tests/local-storage.test-d.ts b/packages/db/tests/local-storage.test-d.ts index 3321b2e66..30be322f4 100644 --- a/packages/db/tests/local-storage.test-d.ts +++ b/packages/db/tests/local-storage.test-d.ts @@ -7,8 +7,10 @@ import type { StorageApi, StorageEventApi, } from '../src/local-storage' +import type { OutputWithVirtual } from './utils' type ItemOf = T extends Array ? U : T +type OutputWithVirtualString = OutputWithVirtual describe(`LocalStorage collection type resolution tests`, () => { // Define test types @@ -315,11 +317,11 @@ describe(`LocalStorage collection type resolution tests`, () => { type ExpectedInput = z.input const collection = createCollection( - localStorageCollectionOptions({ + localStorageCollectionOptions({ storageKey: `test-with-schema`, storage: mockStorage, storageEventApi: mockStorageEventApi, - getKey: (item: any) => item.id, + getKey: (item: ExpectedType) => item.id, schema: testSchemaWithSchema, onInsert: (params) => { expectTypeOf( @@ -358,7 +360,9 @@ describe(`LocalStorage collection type resolution tests`, () => { }) // Test that the collection has the correct inferred type from schema - expectTypeOf(collection.toArray).toEqualTypeOf>() + expectTypeOf(collection.toArray).toEqualTypeOf< + Array> + >() }) it(`should work with explicit type for URL scenario`, () => { @@ -369,11 +373,11 @@ describe(`LocalStorage collection type resolution tests`, () => { createdAt: Date } - const options = localStorageCollectionOptions({ + const options = localStorageCollectionOptions({ storageKey: `test-with-url-type`, storage: mockStorage, storageEventApi: mockStorageEventApi, - getKey: (url) => url.id, + getKey: (url: SelectUrlType) => url.id, }) const collection = createCollection(options) @@ -381,9 +385,11 @@ describe(`LocalStorage collection type resolution tests`, () => { // Test that the collection has the expected methods expectTypeOf(collection.insert).toBeFunction() expectTypeOf(collection.get).returns.toEqualTypeOf< - SelectUrlType | undefined + OutputWithVirtualString | undefined + >() + expectTypeOf(collection.toArray).toEqualTypeOf< + Array> >() - expectTypeOf(collection.toArray).toEqualTypeOf>() // Test insert parameter type type InsertParam = Parameters[0] diff --git a/packages/db/tests/optimistic-action.test.ts b/packages/db/tests/optimistic-action.test.ts index 5b2572bc9..6b346e7c4 100644 --- a/packages/db/tests/optimistic-action.test.ts +++ b/packages/db/tests/optimistic-action.test.ts @@ -5,6 +5,7 @@ import type { Transaction, TransactionWithMutations, } from '../src' +import { stripVirtualProps } from './utils' describe(`createOptimisticAction`, () => { // Runtime tests @@ -47,7 +48,10 @@ describe(`createOptimisticAction`, () => { expect(onMutateMock).toHaveBeenCalledWith(`Test Todo`) // Verify the optimistic update was applied to the collection - expect(collection.get(`1`)).toEqual({ id: `1`, text: `Test Todo` }) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + text: `Test Todo`, + }) // Wait for the mutation to complete await transaction.isPersisted.promise @@ -141,7 +145,7 @@ describe(`createOptimisticAction`, () => { expect(onMutateMock).toHaveBeenCalledWith(todoData) // Verify the optimistic update was applied to the collection - expect(collection.get(`2`)).toEqual(todoData) + expect(stripVirtualProps(collection.get(`2`))).toEqual(todoData) // Wait for the mutation to complete await transaction.isPersisted.promise @@ -234,7 +238,10 @@ describe(`createOptimisticAction`, () => { const transaction = failingAction(`Will Fail`) // Verify the optimistic update was applied - expect(collection.get(`3`)).toEqual({ id: `3`, text: `Will Fail` }) + expect(stripVirtualProps(collection.get(`3`))).toEqual({ + id: `3`, + text: `Will Fail`, + }) // Wait for the transaction to complete (it will fail) try { diff --git a/packages/db/tests/query/basic.test-d.ts b/packages/db/tests/query/basic.test-d.ts index f4923172f..710cbbfc5 100644 --- a/packages/db/tests/query/basic.test-d.ts +++ b/packages/db/tests/query/basic.test-d.ts @@ -4,6 +4,7 @@ import { type } from 'arktype' import { createLiveQueryCollection, eq, gt } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' import { mockSyncCollectionOptions } from '../utils.js' +import type { OutputWithVirtual } from '../utils.js' // Sample user type for tests type User = { @@ -14,6 +15,11 @@ type User = { active: boolean } +type OutputWithVirtualKeyed = OutputWithVirtual< + T, + string | number +> + // Sample data for tests const sampleUsers: Array = [ { id: 1, name: `Alice`, age: 25, email: `alice@example.com`, active: true }, @@ -46,14 +52,16 @@ describe(`Query Basic Types`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - id: number - name: string - age: number - email: string - active: boolean - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + id: number + name: string + age: number + email: string + active: boolean + }> + > >() }) @@ -69,14 +77,16 @@ describe(`Query Basic Types`, () => { ) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - id: number - name: string - age: number - email: string - active: boolean - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + id: number + name: string + age: number + email: string + active: boolean + }> + > >() }) @@ -94,12 +104,14 @@ describe(`Query Basic Types`, () => { }) const results = activeLiveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - id: number - name: string - active: boolean - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + id: number + name: string + active: boolean + }> + > >() }) @@ -119,14 +131,16 @@ describe(`Query Basic Types`, () => { }) const results = projectedLiveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - id: number - name: string - isAdult: number - literalConst: `one` - literal: string - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + id: number + name: string + isAdult: number + literalConst: `one` + literal: string + }> + > >() }) @@ -142,11 +156,13 @@ describe(`Query Basic Types`, () => { }) const results = customKeyCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - userId: number - userName: string - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + userId: number + userName: string + }> + > >() }) @@ -171,19 +187,23 @@ describe(`Query Basic Types`, () => { }) const results1 = collection1.toArray - expectTypeOf(results1).toEqualTypeOf< - Array<{ - id: number - name: string - }> + expectTypeOf(results1).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + id: number + name: string + }> + > >() const results2 = collection2.toArray - expectTypeOf(results2).toEqualTypeOf< - Array<{ - id: number - name: string - }> + expectTypeOf(results2).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + id: number + name: string + }> + > >() }) @@ -194,7 +214,7 @@ describe(`Query Basic Types`, () => { const results = liveCollection.toArray // Should return the original User type, not namespaced - expectTypeOf(results).toEqualTypeOf>() + expectTypeOf(results).toMatchTypeOf>>() }) test(`no select with WHERE returns original collection type`, () => { @@ -207,7 +227,7 @@ describe(`Query Basic Types`, () => { const results = activeLiveCollection.toArray // Should return the original User type, not namespaced - expectTypeOf(results).toEqualTypeOf>() + expectTypeOf(results).toMatchTypeOf>>() }) test(`query function syntax with no select returns original type`, () => { @@ -217,7 +237,7 @@ describe(`Query Basic Types`, () => { const results = liveCollection.toArray // Should return the original User type, not namespaced - expectTypeOf(results).toEqualTypeOf>() + expectTypeOf(results).toMatchTypeOf>>() }) test(`selecting optional field should work`, () => { @@ -244,10 +264,12 @@ describe(`Query Basic Types`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - inserted_at: Date | undefined - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + inserted_at: Date | undefined + }> + > >() }) @@ -279,10 +301,12 @@ describe(`Query Basic Types`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - inserted_at: Date | undefined - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + inserted_at: Date | undefined + }> + > >() }) @@ -315,11 +339,13 @@ describe(`Query Basic Types`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - status: `active` | `inactive` | undefined - name: string - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + status: `active` | `inactive` | undefined + name: string + }> + > >() }) }) @@ -353,10 +379,12 @@ describe(`Query Basic Types with ArkType Schemas`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - inserted_at: Date | undefined - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + inserted_at: Date | undefined + }> + > >() }) @@ -389,11 +417,13 @@ describe(`Query Basic Types with ArkType Schemas`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - status: `active` | `inactive` | undefined - name: string - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + status: `active` | `inactive` | undefined + name: string + }> + > >() }) @@ -426,11 +456,13 @@ describe(`Query Basic Types with ArkType Schemas`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - tags: Array | undefined - name: string - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + tags: Array | undefined + name: string + }> + > >() }) @@ -469,13 +501,15 @@ describe(`Query Basic Types with ArkType Schemas`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - id: number - name: string - age: number - email: string | undefined - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + id: number + name: string + age: number + email: string | undefined + }> + > >() }) }) diff --git a/packages/db/tests/query/basic.test.ts b/packages/db/tests/query/basic.test.ts index 1ca9c07d7..9858162b1 100644 --- a/packages/db/tests/query/basic.test.ts +++ b/packages/db/tests/query/basic.test.ts @@ -7,7 +7,7 @@ import { upper, } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' -import { mockSyncCollectionOptions } from '../utils.js' +import { mockSyncCollectionOptions, stripVirtualProps } from '../utils.js' // Sample user type for tests type User = { @@ -546,7 +546,9 @@ function createBasicTests(autoIndex: `off` | `eager`) { expect(results[0]).toHaveProperty(`active`) // Verify the data matches exactly - expect(results).toEqual(expect.arrayContaining(sampleUsers)) + expect(results.map((row) => stripVirtualProps(row))).toEqual( + expect.arrayContaining(sampleUsers), + ) // Insert a new user const newUser = { @@ -564,7 +566,7 @@ function createBasicTests(autoIndex: `off` | `eager`) { usersCollection.utils.commit() expect(liveCollection.size).toBe(5) - expect(liveCollection.get(5)).toEqual(newUser) + expect(stripVirtualProps(liveCollection.get(5))).toEqual(newUser) // Update the new user const updatedUser = { ...newUser, name: `Eve Updated` } @@ -576,7 +578,7 @@ function createBasicTests(autoIndex: `off` | `eager`) { usersCollection.utils.commit() expect(liveCollection.size).toBe(5) - expect(liveCollection.get(5)).toEqual(updatedUser) + expect(stripVirtualProps(liveCollection.get(5))).toEqual(updatedUser) // Delete the new user usersCollection.utils.begin() @@ -634,7 +636,7 @@ function createBasicTests(autoIndex: `off` | `eager`) { usersCollection.utils.commit() expect(activeLiveCollection.size).toBe(4) // Should include the new active user - expect(activeLiveCollection.get(5)).toEqual(newUser) + expect(stripVirtualProps(activeLiveCollection.get(5))).toEqual(newUser) // Update the new user to inactive (should remove from active collection) const inactiveUser = { ...newUser, active: false } diff --git a/packages/db/tests/query/builder/subqueries.test-d.ts b/packages/db/tests/query/builder/subqueries.test-d.ts index f8343e395..75abe8345 100644 --- a/packages/db/tests/query/builder/subqueries.test-d.ts +++ b/packages/db/tests/query/builder/subqueries.test-d.ts @@ -4,6 +4,7 @@ import { CollectionImpl } from '../../../src/collection/index.js' import { avg, count, eq } from '../../../src/query/builder/functions.js' import type { ExtractContext } from '../../../src/query/builder/index.js' import type { GetResult } from '../../../src/query/builder/types.js' +import type { OutputWithVirtual } from '../../utils.js' // Test schema types interface Issue { @@ -16,6 +17,8 @@ interface Issue { createdAt: string } +type IssueWithVirtual = OutputWithVirtual + interface User { id: number name: string @@ -43,9 +46,9 @@ describe(`Subquery Types`, () => { .where(({ issue }) => eq(issue.projectId, 1)) // Check that the baseQuery has the correct result type - expectTypeOf< - GetResult> - >().toEqualTypeOf() + expectTypeOf( + {} as GetResult>, + ).toMatchTypeOf() }) test(`subquery in from clause without any cast`, () => { @@ -97,10 +100,12 @@ describe(`Subquery Types`, () => { // Verify the result type type QueryResult = GetResult> - expectTypeOf().toEqualTypeOf<{ - id: number - title: string - }>() + expectTypeOf().toMatchTypeOf< + OutputWithVirtual<{ + id: number + title: string + }> + >() }) }) @@ -124,11 +129,13 @@ describe(`Subquery Types`, () => { // Verify the result type type QueryResult = GetResult> - expectTypeOf().toEqualTypeOf<{ - issueId: number - issueTitle: string - userName: string | undefined - }>() + expectTypeOf().toMatchTypeOf< + OutputWithVirtual<{ + issueId: number + issueTitle: string + userName: string | undefined + }> + >() }) test(`subquery with select in join preserves selected type`, () => { @@ -153,10 +160,12 @@ describe(`Subquery Types`, () => { // Verify the result type type QueryResult = GetResult> - expectTypeOf().toEqualTypeOf<{ - issueId: number - userName: string | undefined - }>() + expectTypeOf().toMatchTypeOf< + OutputWithVirtual<{ + issueId: number + userName: string | undefined + }> + >() }) }) @@ -176,10 +185,12 @@ describe(`Subquery Types`, () => { // Verify the result type type AggregateResult = GetResult> - expectTypeOf().toEqualTypeOf<{ - count: number - avgDuration: number - }>() + expectTypeOf().toMatchTypeOf< + OutputWithVirtual<{ + count: number + avgDuration: number + }> + >() }) test(`group by queries with subqueries`, () => { @@ -199,11 +210,13 @@ describe(`Subquery Types`, () => { // Verify the result type type GroupedResult = GetResult> - expectTypeOf().toEqualTypeOf<{ - status: `open` | `in_progress` | `closed` - count: number - avgDuration: number - }>() + expectTypeOf().toMatchTypeOf< + OutputWithVirtual<{ + status: `open` | `in_progress` | `closed` + count: number + avgDuration: number + }> + >() }) }) @@ -229,10 +242,12 @@ describe(`Subquery Types`, () => { // Verify the result type type QueryResult = GetResult> - expectTypeOf().toEqualTypeOf<{ - id: number - title: string - }>() + expectTypeOf().toMatchTypeOf< + OutputWithVirtual<{ + id: number + title: string + }> + >() }) }) @@ -255,10 +270,12 @@ describe(`Subquery Types`, () => { // Verify the result type type QueryResult = GetResult> - expectTypeOf().toEqualTypeOf<{ - issueId: number - userName: string | undefined - }>() + expectTypeOf().toMatchTypeOf< + OutputWithVirtual<{ + issueId: number + userName: string | undefined + }> + >() }) test(`join subquery with collection`, () => { @@ -279,10 +296,12 @@ describe(`Subquery Types`, () => { // Verify the result type type QueryResult = GetResult> - expectTypeOf().toEqualTypeOf<{ - issueId: number - userName: string | undefined - }>() + expectTypeOf().toMatchTypeOf< + OutputWithVirtual<{ + issueId: number + userName: string | undefined + }> + >() }) }) }) diff --git a/packages/db/tests/query/findone-joins.test-d.ts b/packages/db/tests/query/findone-joins.test-d.ts index 849a7811d..862488ab5 100644 --- a/packages/db/tests/query/findone-joins.test-d.ts +++ b/packages/db/tests/query/findone-joins.test-d.ts @@ -2,6 +2,7 @@ import { describe, expectTypeOf, test } from 'vitest' import { createLiveQueryCollection, eq } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' import { mockSyncCollectionOptions } from '../utils.js' +import type { OutputWithVirtual } from '../utils.js' type Todo = { id: string @@ -15,6 +16,11 @@ type TodoOption = { optionText: string } +type OutputWithVirtualKeyed = OutputWithVirtual< + T, + string | number +> + const todoCollection = createCollection( mockSyncCollectionOptions({ id: `test-todos-findone-joins`, @@ -46,11 +52,13 @@ describe(`findOne() with joins`, () => { .findOne(), }) - expectTypeOf(query.toArray).toEqualTypeOf< - Array<{ - todo: Todo - todoOptions: TodoOption | undefined - }> + expectTypeOf(query.toArray).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + todo: OutputWithVirtualKeyed + todoOptions: OutputWithVirtualKeyed | undefined + }> + > >() }) @@ -67,11 +75,13 @@ describe(`findOne() with joins`, () => { .findOne(), }) - expectTypeOf(query.toArray).toEqualTypeOf< - Array<{ - todo: Todo - todoOptions: TodoOption - }> + expectTypeOf(query.toArray).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + todo: OutputWithVirtualKeyed + todoOptions: OutputWithVirtualKeyed + }> + > >() }) @@ -88,11 +98,13 @@ describe(`findOne() with joins`, () => { ), }) - expectTypeOf(query.toArray).toEqualTypeOf< - Array<{ - todo: Todo - todoOptions: TodoOption | undefined - }> + expectTypeOf(query.toArray).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + todo: OutputWithVirtualKeyed + todoOptions: OutputWithVirtualKeyed | undefined + }> + > >() }) @@ -109,11 +121,13 @@ describe(`findOne() with joins`, () => { .findOne(), }) - expectTypeOf(query.toArray).toEqualTypeOf< - Array<{ - todo: Todo | undefined - todoOptions: TodoOption - }> + expectTypeOf(query.toArray).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + todo: OutputWithVirtualKeyed | undefined + todoOptions: OutputWithVirtualKeyed + }> + > >() }) @@ -130,11 +144,13 @@ describe(`findOne() with joins`, () => { .findOne(), }) - expectTypeOf(query.toArray).toEqualTypeOf< - Array<{ - todo: Todo | undefined - todoOptions: TodoOption | undefined - }> + expectTypeOf(query.toArray).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + todo: OutputWithVirtualKeyed | undefined + todoOptions: OutputWithVirtualKeyed | undefined + }> + > >() }) @@ -167,12 +183,14 @@ describe(`findOne() with joins`, () => { .findOne(), }) - expectTypeOf(query.toArray).toEqualTypeOf< - Array<{ - todo: Todo - todoOptions: TodoOption | undefined - tag: TodoTag - }> + expectTypeOf(query.toArray).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + todo: OutputWithVirtualKeyed + todoOptions: OutputWithVirtualKeyed | undefined + tag: OutputWithVirtualKeyed + }> + > >() }) @@ -192,11 +210,13 @@ describe(`findOne() with joins`, () => { .findOne(), }) - expectTypeOf(query.toArray).toEqualTypeOf< - Array<{ - todoText: string - optionText: string | undefined - }> + expectTypeOf(query.toArray).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + todoText: string + optionText: string | undefined + }> + > >() }) @@ -216,11 +236,13 @@ describe(`findOne() with joins`, () => { })), }) - expectTypeOf(query.toArray).toEqualTypeOf< - Array<{ - todoText: string - optionText: string | undefined - }> + expectTypeOf(query.toArray).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + todoText: string + optionText: string | undefined + }> + > >() }) @@ -238,11 +260,13 @@ describe(`findOne() with joins`, () => { .limit(1), }) - expectTypeOf(query.toArray).toEqualTypeOf< - Array<{ - todo: Todo - todoOptions: TodoOption | undefined - }> + expectTypeOf(query.toArray).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + todo: OutputWithVirtualKeyed + todoOptions: OutputWithVirtualKeyed | undefined + }> + > >() }) }) diff --git a/packages/db/tests/query/functional-variants.test-d.ts b/packages/db/tests/query/functional-variants.test-d.ts index f39083047..99f77c8aa 100644 --- a/packages/db/tests/query/functional-variants.test-d.ts +++ b/packages/db/tests/query/functional-variants.test-d.ts @@ -7,6 +7,7 @@ import { } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' import { mockSyncCollectionOptions } from '../utils.js' +import type { OutputWithVirtual } from '../utils.js' // Sample user type for tests type User = { @@ -24,6 +25,9 @@ type Department = { name: string } +type UserRow = OutputWithVirtual +type DepartmentRow = OutputWithVirtual + // Sample data for tests const sampleUsers: Array = [ { @@ -87,12 +91,14 @@ describe(`Functional Variants Types`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - displayName: string - salaryTier: `senior` | `junior` - emailDomain: string - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + displayName: string + salaryTier: `senior` | `junior` + emailDomain: string + }> + > >() }) @@ -121,18 +127,20 @@ describe(`Functional Variants Types`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - profile: { - name: string - age: number - } - compensation: { - salary: number - grade: `A` | `B` | `C` - bonus_eligible: boolean - } - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + profile: { + name: string + age: number + } + compensation: { + salary: number + grade: `A` | `B` | `C` + bonus_eligible: boolean + } + }> + > >() }) @@ -146,7 +154,7 @@ describe(`Functional Variants Types`, () => { const results = liveCollection.toArray // Should return the original User type since no select transformation - expectTypeOf(results).toEqualTypeOf>() + expectTypeOf(results).toMatchTypeOf>() }) test(`fn.where with regular where clause`, () => { @@ -160,7 +168,7 @@ describe(`Functional Variants Types`, () => { const results = liveCollection.toArray // Should return the original User type - expectTypeOf(results).toEqualTypeOf>() + expectTypeOf(results).toMatchTypeOf>() }) test(`fn.having with GROUP BY return type`, () => { @@ -177,11 +185,13 @@ describe(`Functional Variants Types`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - department_id: number | null - employee_count: number - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + department_id: number | null + employee_count: number + }> + > >() }) @@ -195,7 +205,7 @@ describe(`Functional Variants Types`, () => { const results = liveCollection.toArray // Should return the original User type when used as filter - expectTypeOf(results).toEqualTypeOf>() + expectTypeOf(results).toMatchTypeOf>() }) test(`joins with fn.select return type`, () => { @@ -219,15 +229,17 @@ describe(`Functional Variants Types`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - employeeInfo: string - isHighEarner: boolean - departmentDetails: { - id: number - name: string - } | null - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + employeeInfo: string + isHighEarner: boolean + departmentDetails: { + id: number + name: string + } | null + }> + > >() }) @@ -247,11 +259,13 @@ describe(`Functional Variants Types`, () => { const results = liveCollection.toArray // Should return namespaced joined type since no select - expectTypeOf(results).toEqualTypeOf< - Array<{ - user: User - dept: Department | undefined - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + user: UserRow + dept: DepartmentRow | undefined + }> + > >() }) @@ -273,12 +287,14 @@ describe(`Functional Variants Types`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - departmentName: string - employeeName: string - salary: number - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + departmentName: string + employeeName: string + salary: number + }> + > >() }) @@ -306,12 +322,14 @@ describe(`Functional Variants Types`, () => { const results = liveCollection.toArray // Should use functional select type, not regular select type - expectTypeOf(results).toEqualTypeOf< - Array<{ - employeeId: number - displayName: string - status: `Active` | `Inactive` - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + employeeId: number + displayName: string + status: `Active` | `Inactive` + }> + > >() }) @@ -334,11 +352,13 @@ describe(`Functional Variants Types`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - customName: string - isAdult: boolean - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + customName: string + isAdult: boolean + }> + > >() }) @@ -385,20 +405,22 @@ describe(`Functional Variants Types`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - profile: string - compensation: { - salary: number - grade: `A` | `B` | `C` - bonus_eligible: boolean - } - metrics: { - age: number - years_to_retirement: number - performance_bracket: `A` | `B` | `C` - } - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + profile: string + compensation: { + salary: number + grade: `A` | `B` | `C` + bonus_eligible: boolean + } + metrics: { + age: number + years_to_retirement: number + performance_bracket: `A` | `B` | `C` + } + }> + > >() }) @@ -414,11 +436,13 @@ describe(`Functional Variants Types`, () => { ) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - name: string - isActive: boolean - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + name: string + isActive: boolean + }> + > >() }) @@ -434,11 +458,13 @@ describe(`Functional Variants Types`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - userId: number - displayName: string - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + userId: number + displayName: string + }> + > >() }) @@ -460,12 +486,14 @@ describe(`Functional Variants Types`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - departmentId: number | undefined - departmentName: string | undefined - totalEmployees: number - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + departmentId: number | undefined + departmentName: string | undefined + totalEmployees: number + }> + > >() }) @@ -488,12 +516,14 @@ describe(`Functional Variants Types`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - name: string - salaryInThousands: number - ageCategory: `senior` | `mid` | `junior` - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + name: string + salaryInThousands: number + ageCategory: `senior` | `mid` | `junior` + }> + > >() }) @@ -512,12 +542,14 @@ describe(`Functional Variants Types`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - displayName: string - isActive: boolean - salary: number - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + displayName: string + isActive: boolean + salary: number + }> + > >() }) }) diff --git a/packages/db/tests/query/functional-variants.test.ts b/packages/db/tests/query/functional-variants.test.ts index e9913f5d3..8456526b9 100644 --- a/packages/db/tests/query/functional-variants.test.ts +++ b/packages/db/tests/query/functional-variants.test.ts @@ -6,7 +6,7 @@ import { gt, } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' -import { mockSyncCollectionOptions } from '../utils.js' +import { mockSyncCollectionOptions, stripVirtualProps } from '../utils.js' // Sample user type for tests type User = { @@ -124,14 +124,14 @@ describe(`Functional Variants Query`, () => { // Verify transformations const alice = results.find((u) => u.displayName.includes(`Alice`)) - expect(alice).toEqual({ + expect(stripVirtualProps(alice)).toEqual({ displayName: `Alice (1)`, salaryTier: `senior`, emailDomain: `example.com`, }) const bob = results.find((u) => u.displayName.includes(`Bob`)) - expect(bob).toEqual({ + expect(stripVirtualProps(bob)).toEqual({ displayName: `Bob (2)`, salaryTier: `junior`, emailDomain: `example.com`, @@ -153,7 +153,7 @@ describe(`Functional Variants Query`, () => { expect(liveCollection.size).toBe(6) const frank = liveCollection.get(6) - expect(frank).toEqual({ + expect(stripVirtualProps(frank)).toEqual({ displayName: `Frank (6)`, salaryTier: `senior`, emailDomain: `company.com`, @@ -166,7 +166,7 @@ describe(`Functional Variants Query`, () => { usersCollection.utils.commit() const franklin = liveCollection.get(6) - expect(franklin).toEqual({ + expect(stripVirtualProps(franklin)).toEqual({ displayName: `Franklin (6)`, salaryTier: `junior`, // Changed due to salary update emailDomain: `company.com`, @@ -206,14 +206,14 @@ describe(`Functional Variants Query`, () => { expect(results).toHaveLength(5) // All 5 users included with left join const alice = results.find((r) => r.employeeInfo.includes(`Alice`)) - expect(alice).toEqual({ + expect(stripVirtualProps(alice)).toEqual({ employeeInfo: `Alice works in Engineering`, isHighEarner: true, yearsToRetirement: 40, }) const eve = results.find((r) => r.employeeInfo.includes(`Eve`)) - expect(eve).toEqual({ + expect(stripVirtualProps(eve)).toEqual({ employeeInfo: `Eve works in Unknown`, isHighEarner: false, yearsToRetirement: 37, @@ -259,7 +259,7 @@ describe(`Functional Variants Query`, () => { usersCollection.utils.commit() expect(liveCollection.size).toBe(3) - expect(liveCollection.get(6)).toEqual(newUser) + expect(stripVirtualProps(liveCollection.get(6))).toEqual(newUser) // Insert user that doesn't meet criteria (too young) const youngUser = { @@ -285,7 +285,7 @@ describe(`Functional Variants Query`, () => { usersCollection.utils.commit() expect(liveCollection.size).toBe(4) // Now includes Grace - expect(liveCollection.get(7)).toEqual(olderGrace) + expect(stripVirtualProps(liveCollection.get(7))).toEqual(olderGrace) // Clean up usersCollection.utils.begin() @@ -384,8 +384,14 @@ describe(`Functional Variants Query`, () => { const dept1 = results.find((r) => r.department_id === 1) const dept2 = results.find((r) => r.department_id === 2) - expect(dept1).toEqual({ department_id: 1, employee_count: 2 }) - expect(dept2).toEqual({ department_id: 2, employee_count: 2 }) + expect(stripVirtualProps(dept1)).toEqual({ + department_id: 1, + employee_count: 2, + }) + expect(stripVirtualProps(dept2)).toEqual({ + department_id: 2, + employee_count: 2, + }) // Add another user to department 1 const newUser = { @@ -403,7 +409,10 @@ describe(`Functional Variants Query`, () => { expect(liveCollection.size).toBe(2) // Still 2 departments const updatedDept1 = liveCollection.get(1) - expect(updatedDept1).toEqual({ department_id: 1, employee_count: 3 }) // Now 3 employees + expect(stripVirtualProps(updatedDept1)).toEqual({ + department_id: 1, + employee_count: 3, + }) // Now 3 employees // Remove one user from department 1 const bobUser = sampleUsers.find((u) => u.name === `Bob`) @@ -414,7 +423,10 @@ describe(`Functional Variants Query`, () => { expect(liveCollection.size).toBe(2) // Still 2 departments (dept 1 has Alice+Frank, dept 2 has Charlie+Dave) const dept1After = liveCollection.get(1) - expect(dept1After).toEqual({ department_id: 1, employee_count: 2 }) // Alice + Frank = 2 employees + expect(stripVirtualProps(dept1After)).toEqual({ + department_id: 1, + employee_count: 2, + }) // Alice + Frank = 2 employees // Clean up usersCollection.utils.begin() @@ -459,7 +471,7 @@ describe(`Functional Variants Query`, () => { usersCollection.utils.commit() expect(liveCollection.size).toBe(2) - expect(liveCollection.get(6)).toEqual(newUser) + expect(stripVirtualProps(liveCollection.get(6))).toEqual(newUser) // Update to not meet criteria (too old) const olderFrank = { ...newUser, age: 35 } @@ -725,14 +737,14 @@ describe(`Functional Variants Query`, () => { expect(results).toHaveLength(2) const alice = results.find((r) => r.employeeName === `Alice`) - expect(alice).toEqual({ + expect(stripVirtualProps(alice)).toEqual({ departmentName: `Engineering`, employeeName: `Alice`, salary: 75000, }) const dave = results.find((r) => r.employeeName === `Dave`) - expect(dave).toEqual({ + expect(stripVirtualProps(dave)).toEqual({ departmentName: `Marketing`, employeeName: `Dave`, salary: 65000, @@ -776,7 +788,7 @@ describe(`Functional Variants Query`, () => { }) const alice = results.find((r) => r.displayName.includes(`Alice`)) - expect(alice).toEqual({ + expect(stripVirtualProps(alice)).toEqual({ employeeId: 1, displayName: `Employee: Alice`, status: `Active`, @@ -833,7 +845,7 @@ describe(`Functional Variants Query`, () => { expect(results).toHaveLength(2) const alice = results.find((r) => r.profile.includes(`Alice`)) - expect(alice).toEqual({ + expect(stripVirtualProps(alice)).toEqual({ profile: `Alice (Mid)`, compensation: { salary: 75000, @@ -848,7 +860,7 @@ describe(`Functional Variants Query`, () => { }) const eve = results.find((r) => r.profile.includes(`Eve`)) - expect(eve).toEqual({ + expect(stripVirtualProps(eve)).toEqual({ profile: `Eve (Mid)`, compensation: { salary: 55000, diff --git a/packages/db/tests/query/group-by.test-d.ts b/packages/db/tests/query/group-by.test-d.ts index 2f6a4c8c3..583b404d0 100644 --- a/packages/db/tests/query/group-by.test-d.ts +++ b/packages/db/tests/query/group-by.test-d.ts @@ -15,6 +15,7 @@ import { or, sum, } from '../../src/query/builder/functions.js' +import type { OutputWithVirtual } from '../utils.js' // Sample data types for comprehensive GROUP BY testing type Order = { @@ -90,8 +91,8 @@ describe(`Query GROUP BY Types`, () => { }) const customer1 = customerSummary.get(1) - expectTypeOf(customer1).toEqualTypeOf< - | { + expectTypeOf(customer1).toMatchTypeOf< + | OutputWithVirtual<{ customer_id: number total_amount: number order_count: number @@ -100,7 +101,7 @@ describe(`Query GROUP BY Types`, () => { max_amount: number min_date: Date max_date: Date - } + }> | undefined >() }) @@ -120,13 +121,13 @@ describe(`Query GROUP BY Types`, () => { }) const completed = statusSummary.get(`completed`) - expectTypeOf(completed).toEqualTypeOf< - | { + expectTypeOf(completed).toMatchTypeOf< + | OutputWithVirtual<{ status: string total_amount: number order_count: number avg_amount: number - } + }> | undefined >() }) @@ -146,13 +147,13 @@ describe(`Query GROUP BY Types`, () => { }) const electronics = categorySummary.get(`electronics`) - expectTypeOf(electronics).toEqualTypeOf< - | { + expectTypeOf(electronics).toMatchTypeOf< + | OutputWithVirtual<{ product_category: string total_quantity: number order_count: number total_amount: number - } + }> | undefined >() }) @@ -172,13 +173,13 @@ describe(`Query GROUP BY Types`, () => { }) const customer1Completed = customerStatusSummary.get(`[1,"completed"]`) - expectTypeOf(customer1Completed).toEqualTypeOf< - | { + expectTypeOf(customer1Completed).toMatchTypeOf< + | OutputWithVirtual<{ customer_id: number status: string total_amount: number order_count: number - } + }> | undefined >() }) @@ -198,12 +199,12 @@ describe(`Query GROUP BY Types`, () => { }) const customer1 = completedOrdersSummary.get(1) - expectTypeOf(customer1).toEqualTypeOf< - | { + expectTypeOf(customer1).toMatchTypeOf< + | OutputWithVirtual<{ customer_id: number total_amount: number order_count: number - } + }> | undefined >() }) @@ -223,12 +224,12 @@ describe(`Query GROUP BY Types`, () => { }) const customer1 = highVolumeCustomers.get(1) - expectTypeOf(customer1).toEqualTypeOf< - | { + expectTypeOf(customer1).toMatchTypeOf< + | OutputWithVirtual<{ customer_id: number total_amount: number order_count: number - } + }> | undefined >() }) @@ -249,13 +250,13 @@ describe(`Query GROUP BY Types`, () => { }) const customer1 = highValueCustomers.get(1) - expectTypeOf(customer1).toEqualTypeOf< - | { + expectTypeOf(customer1).toMatchTypeOf< + | OutputWithVirtual<{ customer_id: number total_amount: number order_count: number avg_amount: number - } + }> | undefined >() }) @@ -276,13 +277,13 @@ describe(`Query GROUP BY Types`, () => { }) const customer1 = consistentCustomers.get(1) - expectTypeOf(customer1).toEqualTypeOf< - | { + expectTypeOf(customer1).toMatchTypeOf< + | OutputWithVirtual<{ customer_id: number total_amount: number order_count: number avg_amount: number - } + }> | undefined >() }) @@ -305,13 +306,13 @@ describe(`Query GROUP BY Types`, () => { }) const customer1 = premiumCustomers.get(1) - expectTypeOf(customer1).toEqualTypeOf< - | { + expectTypeOf(customer1).toMatchTypeOf< + | OutputWithVirtual<{ customer_id: number total_amount: number order_count: number avg_amount: number - } + }> | undefined >() }) @@ -334,13 +335,13 @@ describe(`Query GROUP BY Types`, () => { }) const customer1 = interestingCustomers.get(1) - expectTypeOf(customer1).toEqualTypeOf< - | { + expectTypeOf(customer1).toMatchTypeOf< + | OutputWithVirtual<{ customer_id: number total_amount: number order_count: number min_amount: number - } + }> | undefined >() }) @@ -359,12 +360,12 @@ describe(`Query GROUP BY Types`, () => { }) const salesRep1 = salesRepSummary.get(1) - expectTypeOf(salesRep1).toEqualTypeOf< - | { + expectTypeOf(salesRep1).toMatchTypeOf< + | OutputWithVirtual<{ sales_rep_id: number | null total_amount: number order_count: number - } + }> | undefined >() }) @@ -390,8 +391,8 @@ describe(`Query GROUP BY Types`, () => { }) const customer1 = comprehensiveStats.get(1) - expectTypeOf(customer1).toEqualTypeOf< - | { + expectTypeOf(customer1).toMatchTypeOf< + | OutputWithVirtual<{ customer_id: number order_count: number total_amount: number @@ -402,7 +403,7 @@ describe(`Query GROUP BY Types`, () => { avg_quantity: number min_quantity: number max_quantity: number - } + }> | undefined >() }) diff --git a/packages/db/tests/query/group-by.test.ts b/packages/db/tests/query/group-by.test.ts index 62f112b83..29fe7e48a 100644 --- a/packages/db/tests/query/group-by.test.ts +++ b/packages/db/tests/query/group-by.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, test } from 'vitest' import { createLiveQueryCollection } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' -import { mockSyncCollectionOptions } from '../utils.js' +import { mockSyncCollectionOptions, stripVirtualProps } from '../utils.js' import { and, avg, @@ -1787,7 +1787,9 @@ function createGroupByTests(autoIndex: `off` | `eager`): void { .orderBy(({ $selected }) => $selected.latestActivity), }) - expect(sessionStats.toArray).toEqual([ + expect( + sessionStats.toArray.map((row) => stripVirtualProps(row)), + ).toEqual([ { taskId: 2, latestActivity: new Date(`2023-02-01`), @@ -1819,7 +1821,9 @@ function createGroupByTests(autoIndex: `off` | `eager`): void { }), }) - expect(sessionStats.toArray).toEqual([ + expect( + sessionStats.toArray.map((row) => stripVirtualProps(row)), + ).toEqual([ { taskId: 2, latestActivity: new Date(`2023-02-01`), @@ -1849,7 +1853,9 @@ function createGroupByTests(autoIndex: `off` | `eager`): void { .having(({ $selected }) => gt($selected.sessionCount, 2)), }) - expect(sessionStats.toArray).toEqual([ + expect( + sessionStats.toArray.map((row) => stripVirtualProps(row)), + ).toEqual([ { taskId: 1, latestActivity: new Date(`2023-03-01`), diff --git a/packages/db/tests/query/indexes.test.ts b/packages/db/tests/query/indexes.test.ts index 9b221443c..3b3a173c9 100644 --- a/packages/db/tests/query/indexes.test.ts +++ b/packages/db/tests/query/indexes.test.ts @@ -12,7 +12,7 @@ import { length, or, } from '../../src/query/builder/functions' -import { mockSyncCollectionOptions } from '../utils' +import { mockSyncCollectionOptions, stripVirtualProps } from '../utils' interface TestItem { id: string @@ -765,7 +765,7 @@ describe(`Query Index Optimization`, () => { await liveQuery.stateWhenReady() // Should have found results where both items are active - expect(liveQuery.toArray).toEqual([ + expect(liveQuery.toArray.map((row) => stripVirtualProps(row))).toEqual([ { id: `1`, name: `Alice`, otherName: `Other Active Item` }, ]) @@ -959,7 +959,7 @@ describe(`Query Index Optimization`, () => { // Should only include results where both sides match the WHERE condition // Charlie and Eve are filtered out because they have no matching 'other' records // and the WHERE clause requires other.status = 'active' (can't be NULL) - expect(liveQuery.toArray).toEqual([ + expect(liveQuery.toArray.map((row) => stripVirtualProps(row))).toEqual([ { id: `1`, name: `Alice`, otherName: `Other Active Item` }, ]) @@ -1078,7 +1078,7 @@ describe(`Query Index Optimization`, () => { // Should only include results where both sides match the WHERE condition // Charlie and Eve are filtered out because they have no matching 'other' records // and the WHERE clause requires other.status = 'active' (can't be NULL) - expect(liveQuery.toArray).toEqual([ + expect(liveQuery.toArray.map((row) => stripVirtualProps(row))).toEqual([ { id: `1`, name: `Alice`, otherName: `Other Active Item` }, ]) @@ -1173,7 +1173,7 @@ describe(`Query Index Optimization`, () => { await liveQuery.stateWhenReady() // Should include all results from the first collection - expect(liveQuery.toArray).toEqual([ + expect(liveQuery.toArray.map((row) => stripVirtualProps(row))).toEqual([ { id: `1`, name: `Alice`, otherName: `Other Active Item` }, ]) @@ -1269,7 +1269,7 @@ describe(`Query Index Optimization`, () => { await liveQuery.stateWhenReady() // Should have found results where both items are active - expect(liveQuery.toArray).toEqual([ + expect(liveQuery.toArray.map((row) => stripVirtualProps(row))).toEqual([ { id: `1`, name: `Alice`, otherName: `Other Active Item` }, ]) @@ -1320,7 +1320,7 @@ describe(`Query Index Optimization`, () => { // Should have found limited results expect(liveQuery.size).toBe(2) - expect(liveQuery.toArray).toEqual([ + expect(liveQuery.toArray.map((row) => stripVirtualProps(row))).toEqual([ { id: `5`, name: `Eve`, age: 22 }, { id: `1`, name: `Alice`, age: 25 }, ]) @@ -1341,7 +1341,7 @@ describe(`Query Index Optimization`, () => { expect(liveQuery.size).toBe(2) - expect(liveQuery.toArray).toEqual([ + expect(liveQuery.toArray.map((row) => stripVirtualProps(row))).toEqual([ { id: `6`, name: `Dave`, age: 20 }, { id: `5`, name: `Eve`, age: 22 }, ]) @@ -1385,7 +1385,7 @@ describe(`Query Index Optimization`, () => { expect(liveQuery.size).toBe(6) - expect(liveQuery.toArray).toEqual([ + expect(liveQuery.toArray.map((row) => stripVirtualProps(row))).toEqual([ { id: `5`, name: `Eve`, age: 22 }, { id: `1`, name: `Alice`, age: 25 }, { id: `4`, name: `Diana`, age: 28 }, diff --git a/packages/db/tests/query/join-subquery.test-d.ts b/packages/db/tests/query/join-subquery.test-d.ts index 29d411854..9356eb0ca 100644 --- a/packages/db/tests/query/join-subquery.test-d.ts +++ b/packages/db/tests/query/join-subquery.test-d.ts @@ -2,6 +2,7 @@ import { describe, expectTypeOf, test } from 'vitest' import { createLiveQueryCollection, eq, gt } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' import { mockSyncCollectionOptions } from '../utils.js' +import type { OutputWithVirtual } from '../utils.js' // Sample data types for join-subquery testing type Issue = { @@ -113,13 +114,15 @@ describe(`Join Subquery Types`, () => { }) // Should infer the correct joined result type - expectTypeOf(joinQuery.toArray).toEqualTypeOf< - Array<{ - issue_title: string - user_name: string - issue_duration: number - user_status: `active` | `inactive` - }> + expectTypeOf(joinQuery.toArray).toMatchTypeOf< + Array< + OutputWithVirtual<{ + issue_title: string + user_name: string + issue_duration: number + user_status: `active` | `inactive` + }> + > >() }) @@ -144,11 +147,13 @@ describe(`Join Subquery Types`, () => { }) // Left join should make the joined table optional in namespaced result - expectTypeOf(joinQuery.toArray).toEqualTypeOf< - Array<{ - issue: Issue - activeUser: User | undefined - }> + expectTypeOf(joinQuery.toArray).toMatchTypeOf< + Array< + OutputWithVirtual<{ + issue: OutputWithVirtual + activeUser: OutputWithVirtual | undefined + }> + > >() }) @@ -185,13 +190,15 @@ describe(`Join Subquery Types`, () => { }) // Should infer the correct result type from both subqueries - expectTypeOf(joinQuery.toArray).toEqualTypeOf< - Array<{ - issue_title: string - issue_duration: number - user_name: string - user_email: string - }> + expectTypeOf(joinQuery.toArray).toMatchTypeOf< + Array< + OutputWithVirtual<{ + issue_title: string + issue_duration: number + user_name: string + user_email: string + }> + > >() }) }) @@ -222,12 +229,14 @@ describe(`Join Subquery Types`, () => { }) // Should infer the correct result type - expectTypeOf(joinQuery.toArray).toEqualTypeOf< - Array<{ - issue_title: string - user_name: string - user_email: string - }> + expectTypeOf(joinQuery.toArray).toMatchTypeOf< + Array< + OutputWithVirtual<{ + issue_title: string + user_name: string + user_email: string + }> + > >() }) @@ -251,11 +260,13 @@ describe(`Join Subquery Types`, () => { }) // Left join should make the joined subquery optional in namespaced result - expectTypeOf(joinQuery.toArray).toEqualTypeOf< - Array<{ - issue: Issue - activeUser: User | undefined - }> + expectTypeOf(joinQuery.toArray).toMatchTypeOf< + Array< + OutputWithVirtual<{ + issue: OutputWithVirtual + activeUser: OutputWithVirtual | undefined + }> + > >() }) @@ -307,16 +318,18 @@ describe(`Join Subquery Types`, () => { }) // Should infer the final transformed and joined type - expectTypeOf(joinQuery.toArray).toEqualTypeOf< - Array<{ - id: number - name: string - effort_hours: number - is_high_priority: boolean - assigned_to: string - contact_email: string - department: number | undefined - }> + expectTypeOf(joinQuery.toArray).toMatchTypeOf< + Array< + OutputWithVirtual<{ + id: number + name: string + effort_hours: number + is_high_priority: boolean + assigned_to: string + contact_email: string + department: number | undefined + }> + > >() }) }) @@ -353,17 +366,19 @@ describe(`Join Subquery Types`, () => { }) // Should infer types with all original Issue properties available - expectTypeOf(joinQuery.toArray).toEqualTypeOf< - Array<{ - issue_id: number - issue_title: string - issue_status: `open` | `in_progress` | `closed` - issue_project_id: number - issue_user_id: number - issue_duration: number - issue_created_at: string - user_name: string - }> + expectTypeOf(joinQuery.toArray).toMatchTypeOf< + Array< + OutputWithVirtual<{ + issue_id: number + issue_title: string + issue_status: `open` | `in_progress` | `closed` + issue_project_id: number + issue_user_id: number + issue_duration: number + issue_created_at: string + user_name: string + }> + > >() }) @@ -393,12 +408,14 @@ describe(`Join Subquery Types`, () => { }) // With the new approach, this should now correctly infer string | undefined for user_name - expectTypeOf(joinQuery.toArray).toEqualTypeOf< - Array<{ - issue_title: string - user_name: string | undefined - issue_status: `open` | `in_progress` | `closed` - }> + expectTypeOf(joinQuery.toArray).toMatchTypeOf< + Array< + OutputWithVirtual<{ + issue_title: string + user_name: string | undefined + issue_status: `open` | `in_progress` | `closed` + }> + > >() }) }) diff --git a/packages/db/tests/query/join-subquery.test.ts b/packages/db/tests/query/join-subquery.test.ts index 259fac7e2..d7f14537d 100644 --- a/packages/db/tests/query/join-subquery.test.ts +++ b/packages/db/tests/query/join-subquery.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, test } from 'vitest' import { createLiveQueryCollection, eq, gt } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' -import { mockSyncCollectionOptions } from '../utils.js' +import { mockSyncCollectionOptions, stripVirtualProps } from '../utils.js' // Sample data types for join-subquery testing type Issue = { @@ -460,7 +460,11 @@ function createJoinSubqueryTests(autoIndex: `off` | `eager`): void { startSync: true, }) - const results = joinSubquery.toArray + const results = joinSubquery.toArray.map((row) => ({ + ...stripVirtualProps(row), + product: stripVirtualProps(row.product), + tried: stripVirtualProps(row.tried), + })) expect(results).toHaveLength(1) expect(results[0]!.product.id).toBe(1) expect(results[0]!.tried).toBeDefined() @@ -493,7 +497,10 @@ function createJoinSubqueryTests(autoIndex: `off` | `eager`): void { startSync: true, }) - const results = joinSubquery.toArray + const results = joinSubquery.toArray.map((row) => ({ + ...stripVirtualProps(row), + issue: stripVirtualProps(row.issue), + })) expect(results).toEqual([ { issue: { @@ -531,7 +538,10 @@ function createJoinSubqueryTests(autoIndex: `off` | `eager`): void { startSync: true, }) - const results = joinSubquery.toArray + const results = joinSubquery.toArray.map((row) => ({ + ...stripVirtualProps(row), + issue: stripVirtualProps(row.issue), + })) expect(results).toEqual([ { issue: { @@ -609,7 +619,9 @@ function createJoinSubqueryTests(autoIndex: `off` | `eager`): void { expect(typeof result.is_high_priority).toBe(`boolean`) }) - const sortedResults = results.sort((a, b) => a.id - b.id) + const sortedResults = results + .map((result) => stripVirtualProps(result)) + .sort((a, b) => a.id - b.id) expect(sortedResults).toEqual([ { id: 1, diff --git a/packages/db/tests/query/join.test-d.ts b/packages/db/tests/query/join.test-d.ts index 9fc1a6bde..922d4e03e 100644 --- a/packages/db/tests/query/join.test-d.ts +++ b/packages/db/tests/query/join.test-d.ts @@ -4,6 +4,7 @@ import { type } from 'arktype' import { createLiveQueryCollection, eq } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' import { mockSyncCollectionOptions } from '../utils.js' +import type { OutputWithVirtual } from '../utils.js' // Sample data types for join type testing type User = { @@ -19,6 +20,9 @@ type Department = { budget: number } +type UserRow = OutputWithVirtual +type DepartmentRow = OutputWithVirtual + function createUsersCollection() { return createCollection( mockSyncCollectionOptions({ @@ -58,11 +62,13 @@ describe(`Join Types - Type Safety`, () => { const results = innerJoinQuery.toArray // For inner joins, both user and dept should be required - expectTypeOf(results).toEqualTypeOf< - Array<{ - user: User - dept: Department - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + user: UserRow + dept: DepartmentRow + }> + > >() }) @@ -84,11 +90,13 @@ describe(`Join Types - Type Safety`, () => { const results = leftJoinQuery.toArray // For left joins, user is required, dept is optional - expectTypeOf(results).toEqualTypeOf< - Array<{ - user: User - dept: Department | undefined - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + user: UserRow + dept: DepartmentRow | undefined + }> + > >() }) @@ -110,11 +118,13 @@ describe(`Join Types - Type Safety`, () => { const results = rightJoinQuery.toArray // For right joins, dept is required, user is optional - expectTypeOf(results).toEqualTypeOf< - Array<{ - user: User | undefined - dept: Department - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + user: UserRow | undefined + dept: DepartmentRow + }> + > >() }) @@ -136,11 +146,13 @@ describe(`Join Types - Type Safety`, () => { const results = fullJoinQuery.toArray // For full joins, both user and dept are optional - expectTypeOf(results).toEqualTypeOf< - Array<{ - user: User | undefined - dept: Department | undefined - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + user: UserRow | undefined + dept: DepartmentRow | undefined + }> + > >() }) @@ -185,12 +197,14 @@ describe(`Join Types - Type Safety`, () => { // - user should be optional (due to right join with project) // - dept should be optional (due to left join) // - project should be required (right join target) - expectTypeOf(results).toEqualTypeOf< - Array<{ - user: User | undefined - dept: Department | undefined - project: Project - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + user: UserRow | undefined + dept: DepartmentRow | undefined + project: OutputWithVirtual + }> + > >() }) @@ -217,12 +231,14 @@ describe(`Join Types - Type Safety`, () => { const results = selectJoinQuery.toArray // Select should return the projected type, not the joined type - expectTypeOf(results).toEqualTypeOf< - Array<{ - userName: string - deptName: string | undefined - deptBudget: number | undefined - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + userName: string + deptName: string | undefined + deptBudget: number | undefined + }> + > >() }) }) @@ -244,11 +260,13 @@ describe(`Join Alias Methods - Type Safety`, () => { const results = leftJoinQuery.toArray // For left joins, user is required, dept is optional - expectTypeOf(results).toEqualTypeOf< - Array<{ - user: User - dept: Department | undefined - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + user: UserRow + dept: DepartmentRow | undefined + }> + > >() }) @@ -268,11 +286,13 @@ describe(`Join Alias Methods - Type Safety`, () => { const results = rightJoinQuery.toArray // For right joins, dept is required, user is optional - expectTypeOf(results).toEqualTypeOf< - Array<{ - user: User | undefined - dept: Department - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + user: UserRow | undefined + dept: DepartmentRow + }> + > >() }) @@ -292,11 +312,13 @@ describe(`Join Alias Methods - Type Safety`, () => { const results = innerJoinQuery.toArray // For inner joins, both user and dept should be required - expectTypeOf(results).toEqualTypeOf< - Array<{ - user: User - dept: Department - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + user: UserRow + dept: DepartmentRow + }> + > >() }) @@ -316,11 +338,13 @@ describe(`Join Alias Methods - Type Safety`, () => { const results = fullJoinQuery.toArray // For full joins, both user and dept are optional - expectTypeOf(results).toEqualTypeOf< - Array<{ - user: User | undefined - dept: Department | undefined - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + user: UserRow | undefined + dept: DepartmentRow | undefined + }> + > >() }) @@ -361,12 +385,14 @@ describe(`Join Alias Methods - Type Safety`, () => { // - user should be optional (due to right join with project) // - dept should be optional (due to left join) // - project should be required (right join target) - expectTypeOf(results).toEqualTypeOf< - Array<{ - user: User | undefined - dept: Department | undefined - project: Project - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + user: UserRow | undefined + dept: DepartmentRow | undefined + project: OutputWithVirtual + }> + > >() }) @@ -391,12 +417,14 @@ describe(`Join Alias Methods - Type Safety`, () => { const results = selectJoinQuery.toArray // Select should return the projected type with correct optionality - expectTypeOf(results).toEqualTypeOf< - Array<{ - userName: string - deptName: string | undefined - deptBudget: number | undefined - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + userName: string + deptName: string | undefined + deptBudget: number | undefined + }> + > >() }) @@ -421,12 +449,14 @@ describe(`Join Alias Methods - Type Safety`, () => { const results = selectInnerJoinQuery.toArray // Select should return the projected type without undefined for inner join - expectTypeOf(results).toEqualTypeOf< - Array<{ - userName: string - deptName: string - deptBudget: number - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + userName: string + deptName: string + deptBudget: number + }> + > >() }) @@ -468,12 +498,14 @@ describe(`Join Alias Methods - Type Safety`, () => { // - user should be required (from clause) // - dept should be optional (left join) // - project should be required (inner join) - expectTypeOf(results).toEqualTypeOf< - Array<{ - user: User - dept: Department | undefined - project: Project - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + user: UserRow + dept: DepartmentRow | undefined + project: OutputWithVirtual + }> + > >() }) @@ -539,18 +571,22 @@ describe(`Join Alias Methods - Type Safety`, () => { const results = liveCollection.toArray const results2 = liveCollection2.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - eventTitle: string - userName: string - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + eventTitle: string + userName: string + }> + > >() - expectTypeOf(results2).toEqualTypeOf< - Array<{ - eventTitle: string - userName: string - }> + expectTypeOf(results2).toMatchTypeOf< + Array< + OutputWithVirtual<{ + eventTitle: string + userName: string + }> + > >() }) @@ -624,18 +660,22 @@ describe(`Join Alias Methods - Type Safety`, () => { const results = liveCollection.toArray const results2 = liveCollection2.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - eventTitle: string - userName: string - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + eventTitle: string + userName: string + }> + > >() - expectTypeOf(results2).toEqualTypeOf< - Array<{ - eventTitle: string - userName: string - }> + expectTypeOf(results2).toMatchTypeOf< + Array< + OutputWithVirtual<{ + eventTitle: string + userName: string + }> + > >() }) @@ -692,11 +732,13 @@ describe(`Join Alias Methods - Type Safety`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - postTitle: string - authorName: string | undefined - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + postTitle: string + authorName: string | undefined + }> + > >() }) }) @@ -772,18 +814,22 @@ describe(`Join with ArkType Schemas`, () => { const results = liveCollection.toArray const results2 = liveCollection2.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - eventTitle: string - userName: string - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + eventTitle: string + userName: string + }> + > >() - expectTypeOf(results2).toEqualTypeOf< - Array<{ - eventTitle: string - userName: string - }> + expectTypeOf(results2).toMatchTypeOf< + Array< + OutputWithVirtual<{ + eventTitle: string + userName: string + }> + > >() }) @@ -840,11 +886,13 @@ describe(`Join with ArkType Schemas`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - postTitle: string - authorName: string | undefined - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + postTitle: string + authorName: string | undefined + }> + > >() }) @@ -907,14 +955,16 @@ describe(`Join with ArkType Schemas`, () => { }) const results = liveCollection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - postTitle: string - userName: string - userEmail: string - userStatus: `active` | `inactive` | undefined - postCategory: `tech` | `lifestyle` | `news` | undefined - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + postTitle: string + userName: string + userEmail: string + userStatus: `active` | `inactive` | undefined + postCategory: `tech` | `lifestyle` | `news` | undefined + }> + > >() }) }) diff --git a/packages/db/tests/query/join.test.ts b/packages/db/tests/query/join.test.ts index edb632c30..5ec7d44ee 100644 --- a/packages/db/tests/query/join.test.ts +++ b/packages/db/tests/query/join.test.ts @@ -11,7 +11,7 @@ import { or, } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' -import { mockSyncCollectionOptions } from '../utils.js' +import { mockSyncCollectionOptions, stripVirtualProps } from '../utils.js' // Sample data types for join testing type User = { @@ -1291,7 +1291,9 @@ function createJoinTests(autoIndex: `off` | `eager`): void { })), }) - const forwardResults = forwardJoinQuery.toArray + const forwardResults = forwardJoinQuery.toArray.map((row) => + stripVirtualProps(row), + ) expect(forwardResults).toHaveLength(2) // Bob->Alice, Charlie->Alice // Test reverse direction: eq(parentUsers.id, users.parentId) @@ -1311,7 +1313,9 @@ function createJoinTests(autoIndex: `off` | `eager`): void { })), }) - const reverseResults = reverseJoinQuery.toArray + const reverseResults = reverseJoinQuery.toArray.map((row) => + stripVirtualProps(row), + ) expect(reverseResults).toHaveLength(2) // Bob->Alice, Charlie->Alice // Both should produce identical results diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 3c73efa93..0e8f24c71 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -13,6 +13,7 @@ import { flushPromises, mockSyncCollectionOptions, mockSyncCollectionOptionsNoInitialState, + stripVirtualProps, } from '../utils.js' import { createDeferred } from '../../src/deferred' import type { ChangeMessage, LoadSubsetOptions } from '../../src/types.js' @@ -417,8 +418,16 @@ describe(`createLiveQueryCollection`, () => { // The live query should be ready and have the initial data expect(liveQuery.size).toBe(2) // Alice and Charlie are active - expect(liveQuery.get(1)).toEqual({ id: 1, name: `Alice`, active: true }) - expect(liveQuery.get(3)).toEqual({ id: 3, name: `Charlie`, active: true }) + expect(stripVirtualProps(liveQuery.get(1))).toEqual({ + id: 1, + name: `Alice`, + active: true, + }) + expect(stripVirtualProps(liveQuery.get(3))).toEqual({ + id: 3, + name: `Charlie`, + active: true, + }) expect(liveQuery.get(2)).toBeUndefined() // Bob is not active expect(liveQuery.status).toBe(`ready`) @@ -430,7 +439,11 @@ describe(`createLiveQueryCollection`, () => { // The live query should update to include the new data expect(liveQuery.size).toBe(3) // Alice, Charlie, and David are active - expect(liveQuery.get(4)).toEqual({ id: 4, name: `David`, active: true }) + expect(stripVirtualProps(liveQuery.get(4))).toEqual({ + id: 4, + name: `David`, + active: true, + }) }) it(`should not reuse finalized graph after GC cleanup (resubscribe is safe)`, async () => { diff --git a/packages/db/tests/query/nested-props.test-d.ts b/packages/db/tests/query/nested-props.test-d.ts index 6fbe2d56c..4323a7160 100644 --- a/packages/db/tests/query/nested-props.test-d.ts +++ b/packages/db/tests/query/nested-props.test-d.ts @@ -8,6 +8,7 @@ import { } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' import { mockSyncCollectionOptions } from '../utils.js' +import type { OutputWithVirtual } from '../utils.js' // Complex nested type for testing with optional properties type Person = { @@ -98,19 +99,21 @@ describe(`Nested Properties Types`, () => { }) const results = collection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - id: string - name: string - bio: string | undefined // Now optional because profile is optional - score: number | undefined // Now optional because profile is optional - tasksCompleted: number | undefined // Now optional because profile?.stats is optional - rating: number | undefined // Now optional because profile?.stats is optional - city: string | undefined // Now optional because address is optional - country: string | undefined // Now optional because address is optional - lat: number | undefined // Now optional because address?.coordinates is optional - lng: number | undefined // Now optional because address?.coordinates is optional - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + id: string + name: string + bio: string | undefined // Now optional because profile is optional + score: number | undefined // Now optional because profile is optional + tasksCompleted: number | undefined // Now optional because profile?.stats is optional + rating: number | undefined // Now optional because profile?.stats is optional + city: string | undefined // Now optional because address is optional + country: string | undefined // Now optional because address is optional + lat: number | undefined // Now optional because address?.coordinates is optional + lng: number | undefined // Now optional because address?.coordinates is optional + }> + > >() }) @@ -131,11 +134,13 @@ describe(`Nested Properties Types`, () => { }) const results = collection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - id: string - name: string - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + id: string + name: string + }> + > >() }) @@ -161,12 +166,14 @@ describe(`Nested Properties Types`, () => { }) const results = collection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - id: string - name: string - score: number | undefined // Optional because profile is optional - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + id: string + name: string + score: number | undefined // Optional because profile is optional + }> + > >() }) @@ -188,14 +195,16 @@ describe(`Nested Properties Types`, () => { }) const results = collection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - id: string - name: string - score: number | undefined // Optional because profile is optional - lat: number | undefined // Optional because address?.coordinates is optional - rating: number | undefined // Optional because profile?.stats is optional - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + id: string + name: string + score: number | undefined // Optional because profile is optional + lat: number | undefined // Optional because address?.coordinates is optional + rating: number | undefined // Optional because profile?.stats is optional + }> + > >() }) @@ -220,11 +229,13 @@ describe(`Nested Properties Types`, () => { }) const results = collection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - id: string - name: string - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + id: string + name: string + }> + > >() }) @@ -260,20 +271,22 @@ describe(`Nested Properties Types`, () => { }) const results = collection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - id: string - name: string - team: string - bio: string | undefined - score: number | undefined - tasksCompleted: number | undefined - rating: number | undefined - city: string | undefined - country: string | undefined - lat: number | undefined - lng: number | undefined - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + id: string + name: string + team: string + bio: string | undefined + score: number | undefined + tasksCompleted: number | undefined + rating: number | undefined + city: string | undefined + country: string | undefined + lat: number | undefined + lng: number | undefined + }> + > >() }) @@ -291,36 +304,38 @@ describe(`Nested Properties Types`, () => { }) const results = collection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - profileExists: - | { - bio: string - score: number - stats: { - tasksCompleted: number - rating: number + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + profileExists: + | { + bio: string + score: number + stats: { + tasksCompleted: number + rating: number + } } - } - | undefined - addressExists: - | { - city: string - country: string - coordinates: { + | undefined + addressExists: + | { + city: string + country: string + coordinates: { + lat: number + lng: number + } + } + | undefined + score: number | undefined + coordinates: + | { lat: number lng: number } - } - | undefined - score: number | undefined - coordinates: - | { - lat: number - lng: number - } - | undefined - }> + | undefined + }> + > >() }) @@ -336,12 +351,14 @@ describe(`Nested Properties Types`, () => { }) const results = collection.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - id: string - profileScore: number | undefined - coordinatesLat: number | undefined - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + id: string + profileScore: number | undefined + coordinatesLat: number | undefined + }> + > >() }) }) diff --git a/packages/db/tests/query/optional-fields-negative.test-d.ts b/packages/db/tests/query/optional-fields-negative.test-d.ts index 56436d10c..c8b0736cb 100644 --- a/packages/db/tests/query/optional-fields-negative.test-d.ts +++ b/packages/db/tests/query/optional-fields-negative.test-d.ts @@ -2,6 +2,7 @@ import { describe, expectTypeOf, test } from 'vitest' import { createLiveQueryCollection, eq, gt } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' import { mockSyncCollectionOptions } from '../utils.js' +import type { OutputWithVirtual } from '../utils.js' // Test types with optional fields type UserWithOptional = { @@ -18,6 +19,11 @@ type Department = { budget: number } +type OutputWithVirtualKeyed = OutputWithVirtual< + T, + string | number +> + function createUsersCollection() { return createCollection( mockSyncCollectionOptions({ @@ -51,7 +57,9 @@ describe(`Optional Fields - Type Safety Tests`, () => { }) // The query should be typed correctly - expectTypeOf(query.toArray).toEqualTypeOf>() + expectTypeOf(query.toArray).toMatchTypeOf< + Array> + >() }) test(`should allow using optional fields in comparisons with proper type inference`, () => { @@ -65,7 +73,9 @@ describe(`Optional Fields - Type Safety Tests`, () => { }), }) - expectTypeOf(query.toArray).toEqualTypeOf>() + expectTypeOf(query.toArray).toMatchTypeOf< + Array> + >() }) test(`should allow using optional fields in join conditions with proper type inference`, () => { @@ -84,11 +94,13 @@ describe(`Optional Fields - Type Safety Tests`, () => { ), }) - expectTypeOf(query.toArray).toEqualTypeOf< - Array<{ - user: UserWithOptional - dept: Department - }> + expectTypeOf(query.toArray).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + user: OutputWithVirtualKeyed + dept: OutputWithVirtualKeyed + }> + > >() }) @@ -105,12 +117,14 @@ describe(`Optional Fields - Type Safety Tests`, () => { })), }) - expectTypeOf(query.toArray).toEqualTypeOf< - Array<{ - id: string - name: string - email: string | undefined - }> + expectTypeOf(query.toArray).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + id: string + name: string + email: string | undefined + }> + > >() }) @@ -131,10 +145,12 @@ describe(`Optional Fields - Type Safety Tests`, () => { })), }) - expectTypeOf(query.toArray).toEqualTypeOf< - Array<{ - name: string - }> + expectTypeOf(query.toArray).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + name: string + }> + > >() }) @@ -158,11 +174,13 @@ describe(`Optional Fields - Type Safety Tests`, () => { })), }) - expectTypeOf(query.toArray).toEqualTypeOf< - Array<{ - user_name: string - dept_name: string | undefined - }> + expectTypeOf(query.toArray).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + user_name: string + dept_name: string | undefined + }> + > >() }) }) diff --git a/packages/db/tests/query/order-by.test.ts b/packages/db/tests/query/order-by.test.ts index c35760599..eee5a22df 100644 --- a/packages/db/tests/query/order-by.test.ts +++ b/packages/db/tests/query/order-by.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' import { createCollection } from '../../src/collection/index.js' -import { mockSyncCollectionOptions } from '../utils.js' +import { mockSyncCollectionOptions, stripVirtualProps } from '../utils.js' import { createLiveQueryCollection } from '../../src/query/live-query-collection.js' import { eq, @@ -843,7 +843,10 @@ function createOrderByTests(autoIndex: `off` | `eager`): void { }) await liveQuery.stateWhenReady() - expect(liveQuery.toArray).toEqual([{ vin: `1` }, { vin: `2` }]) + expect(liveQuery.toArray.map((row) => stripVirtualProps(row))).toEqual([ + { vin: `1` }, + { vin: `2` }, + ]) // Insert a vehicle document vehicleDocumentCollection.utils.begin() @@ -857,7 +860,7 @@ function createOrderByTests(autoIndex: `off` | `eager`): void { }) vehicleDocumentCollection.utils.commit() - expect(liveQuery.toArray).toEqual([ + expect(liveQuery.toArray.map((row) => stripVirtualProps(row))).toEqual([ { vin: `1` }, { vin: `2` }, { vin: `3` }, @@ -902,7 +905,7 @@ function createOrderByTests(autoIndex: `off` | `eager`): void { }) await liveQuery.stateWhenReady() - expect(liveQuery.toArray).toEqual([ + expect(liveQuery.toArray.map((row) => stripVirtualProps(row))).toEqual([ { vin: `1`, updatedAt: new Date(`2023-01-05`).getTime() }, { vin: `2`, updatedAt: new Date(`2023-01-02`).getTime() }, ]) @@ -919,7 +922,7 @@ function createOrderByTests(autoIndex: `off` | `eager`): void { }) vehicleDocumentCollection.utils.commit() - expect(liveQuery.toArray).toEqual([ + expect(liveQuery.toArray.map((row) => stripVirtualProps(row))).toEqual([ { vin: `1`, updatedAt: new Date(`2023-01-05`).getTime() }, { vin: `3`, updatedAt: new Date(`2023-01-03`).getTime() }, { vin: `2`, updatedAt: new Date(`2023-01-02`).getTime() }, @@ -2547,9 +2550,9 @@ describe(`OrderBy with duplicate values`, () => { await collection.preload() // First page should return items 1-5 - let results = Array.from(collection.values()).sort( - (a, b) => a.id - b.id, - ) + let results = Array.from(collection.values()) + .map((value) => stripVirtualProps(value)) + .sort((a, b) => a.id - b.id) expect(results).toEqual([ { id: 1, a: 1, keep: true }, { id: 2, a: 2, keep: true }, @@ -2563,7 +2566,9 @@ describe(`OrderBy with duplicate values`, () => { await collection.stateWhenReady() // Second page should return items 6-10 (all with value 5) - results = Array.from(collection.values()).sort((a, b) => a.id - b.id) + results = Array.from(collection.values()) + .map((value) => stripVirtualProps(value)) + .sort((a, b) => a.id - b.id) expect(results).toEqual([ { id: 6, a: 5, keep: true }, { id: 7, a: 5, keep: true }, @@ -2579,7 +2584,9 @@ describe(`OrderBy with duplicate values`, () => { // Third page should return items 11-13 (the items after the duplicate 5s) // The bug would cause this to stall and return empty or get stuck - results = Array.from(collection.values()).sort((a, b) => a.id - b.id) + results = Array.from(collection.values()) + .map((value) => stripVirtualProps(value)) + .sort((a, b) => a.id - b.id) expect(results).toEqual([ { id: 11, a: 11, keep: true }, { id: 12, a: 12, keep: true }, @@ -2593,7 +2600,9 @@ describe(`OrderBy with duplicate values`, () => { await collection.stateWhenReady() // Should be empty since we've exhausted all items - results = Array.from(collection.values()) + results = Array.from(collection.values()).map((value) => + stripVirtualProps(value), + ) expect(results).toEqual([{ id: 16, a: 16, keep: true }]) }) @@ -2762,9 +2771,9 @@ describe(`OrderBy with duplicate values`, () => { await collection.preload() // First page should return items 1-5 (all local data) - let results = Array.from(collection.values()).sort( - (a, b) => a.id - b.id, - ) + let results = Array.from(collection.values()) + .map((value) => stripVirtualProps(value)) + .sort((a, b) => a.id - b.id) expect(results).toEqual([ { id: 1, a: 1, keep: true }, { id: 2, a: 2, keep: true }, @@ -2785,7 +2794,9 @@ describe(`OrderBy with duplicate values`, () => { await moveToSecondPage // Second page should return items 6-10 (all with value 5, loaded from sync layer) - results = Array.from(collection.values()).sort((a, b) => a.id - b.id) + results = Array.from(collection.values()) + .map((value) => stripVirtualProps(value)) + .sort((a, b) => a.id - b.id) expect(results).toEqual([ { id: 6, a: 5, keep: true }, { id: 7, a: 5, keep: true }, @@ -2815,7 +2826,9 @@ describe(`OrderBy with duplicate values`, () => { // Third page should return items 11-13 (the items after the duplicate 5s) // The bug would cause this to stall and return empty or get stuck - results = Array.from(collection.values()).sort((a, b) => a.id - b.id) + results = Array.from(collection.values()) + .map((value) => stripVirtualProps(value)) + .sort((a, b) => a.id - b.id) expect(results).toEqual([ { id: 11, a: 11, keep: true }, { id: 12, a: 12, keep: true }, @@ -2994,9 +3007,9 @@ describe(`OrderBy with duplicate values`, () => { await collection.preload() // First page should return items 1-5 (all local data) - let results = Array.from(collection.values()).sort( - (a, b) => a.id - b.id, - ) + let results = Array.from(collection.values()) + .map((value) => stripVirtualProps(value)) + .sort((a, b) => a.id - b.id) expect(results).toEqual([ { id: 1, a: 1, keep: true }, { id: 2, a: 2, keep: true }, @@ -3017,7 +3030,9 @@ describe(`OrderBy with duplicate values`, () => { await moveToSecondPage // Second page should return items 6-10 (all with value 5, loaded from sync layer) - results = Array.from(collection.values()).sort((a, b) => a.id - b.id) + results = Array.from(collection.values()) + .map((value) => stripVirtualProps(value)) + .sort((a, b) => a.id - b.id) expect(results).toEqual([ { id: 6, a: 5, keep: true }, { id: 7, a: 5, keep: true }, @@ -3047,7 +3062,9 @@ describe(`OrderBy with duplicate values`, () => { // Third page should return items 11-13 (the items after the duplicate 5s) // The bug would cause this to stall and return empty or get stuck - results = Array.from(collection.values()).sort((a, b) => a.id - b.id) + results = Array.from(collection.values()) + .map((value) => stripVirtualProps(value)) + .sort((a, b) => a.id - b.id) expect(results).toEqual([ { id: 11, a: 11, keep: true }, { id: 12, a: 12, keep: true }, diff --git a/packages/db/tests/query/query-while-syncing.test.ts b/packages/db/tests/query/query-while-syncing.test.ts index 5616e08a8..0547fcb16 100644 --- a/packages/db/tests/query/query-while-syncing.test.ts +++ b/packages/db/tests/query/query-while-syncing.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { createLiveQueryCollection, eq, gt } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' import { createTransaction } from '../../src/transactions.js' +import { stripVirtualProps } from '../utils.js' // Sample user type for tests type User = { @@ -122,7 +123,7 @@ describe(`Query while syncing`, () => { expect(liveQuery.status).toBe(`ready`) // Final data check - expect(liveQuery.toArray).toEqual([ + expect(liveQuery.toArray.map((row) => stripVirtualProps(row))).toEqual([ { id: 1, name: `Alice` }, { id: 2, name: `Bob` }, { id: 4, name: `Dave` }, @@ -240,9 +241,9 @@ describe(`Query while syncing`, () => { syncCommit!() const result = liveQuery.get(1) - expect(result).toEqual({ id: 1, name: `Alice` }) - expect(result).not.toHaveProperty(`age`) - expect(result).not.toHaveProperty(`active`) + expect(stripVirtualProps(result)).toEqual({ id: 1, name: `Alice` }) + expect(stripVirtualProps(result)).not.toHaveProperty(`age`) + expect(stripVirtualProps(result)).not.toHaveProperty(`active`) expect(liveQuery.status).toBe(`loading`) syncMarkReady!() @@ -335,7 +336,7 @@ describe(`Query while syncing`, () => { expect(usersCollection.size).toBe(1) expect(liveQuery.size).toBe(1) // Should have a join result now - expect(liveQuery.toArray[0]).toEqual({ + expect(stripVirtualProps(liveQuery.toArray[0])).toEqual({ user_name: `Alice`, department_name: `Engineering`, }) @@ -367,7 +368,7 @@ describe(`Query while syncing`, () => { expect(departmentsCollection.status).toBe(`ready`) expect(liveQuery.status).toBe(`ready`) // Now ready because all sources are ready - expect(liveQuery.toArray).toEqual([ + expect(liveQuery.toArray.map((row) => stripVirtualProps(row))).toEqual([ { user_name: `Alice`, department_name: `Engineering` }, { user_name: `Bob`, department_name: `Sales` }, ]) @@ -443,7 +444,7 @@ describe(`Query while syncing`, () => { userSyncCommit!() expect(liveQuery.size).toBe(1) - expect(liveQuery.toArray[0]).toEqual({ + expect(stripVirtualProps(liveQuery.toArray[0])).toEqual({ user_name: `Alice`, department_name: undefined, }) @@ -463,7 +464,7 @@ describe(`Query while syncing`, () => { userSyncCommit!() expect(liveQuery.size).toBe(2) - const results = liveQuery.toArray + const results = liveQuery.toArray.map((row) => stripVirtualProps(row)) expect(results.find((r) => r.user_name === `Alice`)).toEqual({ user_name: `Alice`, department_name: undefined, @@ -748,7 +749,7 @@ describe(`Query while syncing`, () => { await preloadPromise // Final data check - expect(liveQuery.toArray).toEqual([ + expect(liveQuery.toArray.map((row) => stripVirtualProps(row))).toEqual([ { id: 1, name: `Alice` }, { id: 2, name: `Bob` }, { id: 4, name: `Dave` }, @@ -915,7 +916,7 @@ describe(`Query while syncing`, () => { userSyncCommit!() expect(liveQuery.size).toBe(1) // Should have a join result now - expect(liveQuery.toArray[0]).toEqual({ + expect(stripVirtualProps(liveQuery.toArray[0])).toEqual({ user_name: `Alice`, department_name: `Engineering`, }) diff --git a/packages/db/tests/query/scheduler.test.ts b/packages/db/tests/query/scheduler.test.ts index adb85c979..656c585a3 100644 --- a/packages/db/tests/query/scheduler.test.ts +++ b/packages/db/tests/query/scheduler.test.ts @@ -5,7 +5,8 @@ import { createTransaction } from '../../src/transactions.js' import { createOptimisticAction } from '../../src/optimistic-action.js' import { transactionScopedScheduler } from '../../src/scheduler.js' import { CollectionConfigBuilder } from '../../src/query/live/collection-config-builder.js' -import { mockSyncCollectionOptions } from '../utils.js' +import { mockSyncCollectionOptions, stripVirtualProps } from '../utils.js' +import type { OutputWithVirtual } from '../utils.js' import type { FullSyncState } from '../../src/query/live/types.js' import type { SyncConfig } from '../../src/types.js' @@ -19,6 +20,8 @@ interface User { name: string } +type UserWithVirtual = OutputWithVirtual + interface Task { id: number userId: number @@ -299,7 +302,9 @@ describe(`live query scheduler`, () => { collectionB.insert({ id: 1, value: `B1` }) }) - expect(liveQueryJoin.toArray).toEqual([{ left: `A1`, right: `B1` }]) + expect(liveQueryJoin.toArray.map((row) => stripVirtualProps(row))).toEqual([ + { left: `A1`, right: `B1` }, + ]) expect(liveQueryJoin.utils.getRunCount()).toBe(baseRunCount + 1) tx.mutate(() => { @@ -311,7 +316,9 @@ describe(`live query scheduler`, () => { }) }) - expect(liveQueryJoin.toArray).toEqual([{ left: `A1b`, right: `B1b` }]) + expect(liveQueryJoin.toArray.map((row) => stripVirtualProps(row))).toEqual([ + { left: `A1b`, right: `B1b` }, + ]) expect(liveQueryJoin.utils.getRunCount()).toBe(baseRunCount + 2) tx.rollback() }) @@ -382,7 +389,9 @@ describe(`live query scheduler`, () => { collectionB.insert({ id: 7, value: `B7` }) }) - expect(hybridJoin.toArray).toEqual([{ left: `A7`, right: `B7` }]) + expect(hybridJoin.toArray.map((row) => stripVirtualProps(row))).toEqual([ + { left: `A7`, right: `B7` }, + ]) expect(hybridJoin.utils.getRunCount()).toBe(baseRunCount + 1) tx.mutate(() => { @@ -394,7 +403,9 @@ describe(`live query scheduler`, () => { }) }) - expect(hybridJoin.toArray).toEqual([{ left: `A7b`, right: `B7b` }]) + expect(hybridJoin.toArray.map((row) => stripVirtualProps(row))).toEqual([ + { left: `A7b`, right: `B7b` }, + ]) expect(hybridJoin.utils.getRunCount()).toBe(baseRunCount + 2) tx.rollback() }) @@ -465,7 +476,9 @@ describe(`live query scheduler`, () => { collectionA.insert({ id: 42, value: `left-later` }) }) - expect(join.toArray).toEqual([{ left: `left-later`, right: `right-first` }]) + expect(join.toArray.map((row) => stripVirtualProps(row))).toEqual([ + { left: `left-later`, right: `right-first` }, + ]) expect(join.utils.getRunCount()).toBe(baseRunCount + 1) tx.rollback() }) @@ -492,7 +505,7 @@ describe(`live query scheduler`, () => { commit: vi.fn(), markReady: vi.fn(), truncate: vi.fn(), - } as unknown as Parameters[`sync`]>[0] + } as unknown as Parameters[`sync`]>[0] const syncState = { messagesCount: 0, diff --git a/packages/db/tests/query/select-spread.test-d.ts b/packages/db/tests/query/select-spread.test-d.ts index 272c42afb..714e6d488 100644 --- a/packages/db/tests/query/select-spread.test-d.ts +++ b/packages/db/tests/query/select-spread.test-d.ts @@ -3,6 +3,7 @@ import { createCollection } from '../../src/collection/index.js' import { createLiveQueryCollection } from '../../src/query/index.js' import { mockSyncCollectionOptions } from '../utils.js' import { add, length, upper } from '../../src/query/builder/functions.js' +import type { OutputWithVirtual } from '../utils.js' // Base type used in bug report type Message = { @@ -11,6 +12,12 @@ type Message = { user: string } +type OutputWithVirtualKeyed = OutputWithVirtual< + T, + string | number +> +type MessageWithVirtual = OutputWithVirtualKeyed + const initialMessages: Array = [ { id: 1, text: `hello`, user: `sam` }, { id: 2, text: `world`, user: `kim` }, @@ -39,11 +46,15 @@ describe(`Select spread typing`, () => { }) const results = collection.toArray - expectTypeOf(results).toEqualTypeOf>() + expectTypeOf(results).toMatchTypeOf< + Array> + >() // Accessors should also be correctly typed const first = collection.get(1) - expectTypeOf(first).toEqualTypeOf() + expectTypeOf(first).toMatchTypeOf< + OutputWithVirtualKeyed | undefined + >() }) test(`spreading and adding computed fields merges types`, () => { @@ -60,8 +71,12 @@ describe(`Select spread typing`, () => { }) const results = collection.toArray - expectTypeOf(results).toEqualTypeOf< - Array + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtualKeyed< + MessageWithVirtual & { idPlusOne: number; upperText: string } + > + > >(undefined as any) }) @@ -79,8 +94,12 @@ describe(`Select spread typing`, () => { }) const results = collection.toArray - expectTypeOf(results).toEqualTypeOf< - Array & { user: number }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtualKeyed< + Omit & { user: number } + > + > >(undefined as any) }) @@ -106,19 +125,15 @@ describe(`Select spread typing`, () => { }, }) - type Expected = { - id: number - user: string - text: string - theMessage: { + type Expected = MessageWithVirtual & { + theMessage: MessageWithVirtual & { spam: string - id: number - user: string - text: string } } const results = collection.toArray - expectTypeOf(results).toEqualTypeOf>(undefined as any) + expectTypeOf(results).toMatchTypeOf< + Array> + >(undefined as any) }) }) diff --git a/packages/db/tests/query/select-spread.test.ts b/packages/db/tests/query/select-spread.test.ts index 30665f0fe..e87028db0 100644 --- a/packages/db/tests/query/select-spread.test.ts +++ b/packages/db/tests/query/select-spread.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { createLiveQueryCollection } from '../../src/query/index.js' -import { mockSyncCollectionOptions } from '../utils.js' +import { mockSyncCollectionOptions, stripVirtualProps } from '../utils.js' import { add, eq, upper } from '../../src/query/builder/functions.js' // Base type used in bug report @@ -101,9 +101,11 @@ describe(`select spreads (runtime)`, () => { const results = Array.from(collection.values()) expect(results).toHaveLength(2) // Should match initial data exactly - expect(results).toEqual(initialMessages) + expect(results.map((row) => stripVirtualProps(row))).toEqual( + initialMessages, + ) // Index access by key - expect(collection.get(1)).toEqual(initialMessages[0]) + expect(stripVirtualProps(collection.get(1))).toEqual(initialMessages[0]) }) it(`spread + computed fields merges fields with correct values`, async () => { @@ -177,7 +179,11 @@ describe(`select spreads (runtime)`, () => { const results = Array.from(collection.values()) expect(results).toHaveLength(3) - expect(collection.get(3)).toEqual({ id: 3, text: `test`, user: `alex` }) + expect(stripVirtualProps(collection.get(3))).toEqual({ + id: 3, + text: `test`, + user: `alex`, + }) }) it(`spreading preserves nested object fields intact`, async () => { @@ -190,7 +196,7 @@ describe(`select spreads (runtime)`, () => { await collection.preload() const results = Array.from(collection.values()) - expect(results).toEqual(nestedMessages) + 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`) diff --git a/packages/db/tests/query/select.test-d.ts b/packages/db/tests/query/select.test-d.ts index 97549ee4c..225decdfb 100644 --- a/packages/db/tests/query/select.test-d.ts +++ b/packages/db/tests/query/select.test-d.ts @@ -3,6 +3,7 @@ import { createCollection } from '../../src/collection/index.js' import { createLiveQueryCollection } from '../../src/query/index.js' import { mockSyncCollectionOptions } from '../utils.js' import { upper } from '../../src/query/builder/functions.js' +import type { OutputWithVirtual } from '../utils.js' type User = { id: number @@ -25,6 +26,11 @@ type User = { } } +type OutputWithVirtualKeyed = OutputWithVirtual< + T, + string | number +> + function createUsers() { return createCollection( mockSyncCollectionOptions({ @@ -52,7 +58,7 @@ describe(`select types`, () => { const results = col.toArray[0]! - expectTypeOf(results).toEqualTypeOf() + expectTypeOf(results).toMatchTypeOf>() }) test(`works with js built-ins objects`, () => { @@ -75,7 +81,7 @@ describe(`select types`, () => { const results = col.toArray[0]! - expectTypeOf(results).toEqualTypeOf() + expectTypeOf(results).toMatchTypeOf>() }) test(`nested object selection infers nested result type`, () => { @@ -100,7 +106,7 @@ describe(`select types`, () => { const results = col.toArray[0]! - expectTypeOf(results).toEqualTypeOf() + expectTypeOf(results).toMatchTypeOf>() }) test(`nested spread preserves object structure types`, () => { @@ -133,6 +139,6 @@ describe(`select types`, () => { const results = col.toArray[0]! - expectTypeOf(results).toEqualTypeOf() + expectTypeOf(results).toMatchTypeOf>() }) }) diff --git a/packages/db/tests/query/subquery.test-d.ts b/packages/db/tests/query/subquery.test-d.ts index c3906da01..b9a9d38e0 100644 --- a/packages/db/tests/query/subquery.test-d.ts +++ b/packages/db/tests/query/subquery.test-d.ts @@ -2,6 +2,7 @@ import { describe, expectTypeOf, test } from 'vitest' import { createLiveQueryCollection, eq, gt } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' import { mockSyncCollectionOptions } from '../utils.js' +import type { OutputWithVirtual } from '../utils.js' // Sample types for subquery testing type Issue = { @@ -14,6 +15,11 @@ type Issue = { createdAt: string } +type OutputWithVirtualKeyed = OutputWithVirtual< + T, + string | number +> + // Sample data const sampleIssues: Array = [ { @@ -78,12 +84,14 @@ describe(`Subquery Types`, () => { }) // Should infer the correct result type from the SELECT clause - expectTypeOf(liveCollection.toArray).toEqualTypeOf< - Array<{ - id: number - title: string - status: `open` | `in_progress` | `closed` - }> + expectTypeOf(liveCollection.toArray).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + id: number + title: string + status: `open` | `in_progress` | `closed` + }> + > >() }) @@ -100,7 +108,9 @@ describe(`Subquery Types`, () => { }) // Should return the original Issue type - expectTypeOf(liveCollection.toArray).toEqualTypeOf>() + expectTypeOf(liveCollection.toArray).toMatchTypeOf< + Array> + >() }) test(`subquery with SELECT clause transforms type correctly`, () => { @@ -131,13 +141,15 @@ describe(`Subquery Types`, () => { }) // Should infer the final transformed type - expectTypeOf(liveCollection.toArray).toEqualTypeOf< - Array<{ - key: number - title: string - hours: number - type: `open` | `in_progress` | `closed` - }> + expectTypeOf(liveCollection.toArray).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + key: number + title: string + hours: number + type: `open` | `in_progress` | `closed` + }> + > >() }) @@ -171,12 +183,14 @@ describe(`Subquery Types`, () => { }) // Should infer the final nested transformation type - expectTypeOf(liveCollection.toArray).toEqualTypeOf< - Array<{ - id: number - name: string - workHours: number - }> + expectTypeOf(liveCollection.toArray).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + id: number + name: string + workHours: number + }> + > >() }) @@ -198,21 +212,23 @@ describe(`Subquery Types`, () => { }) // Should infer the correct result type - expectTypeOf(customKeyCollection.toArray).toEqualTypeOf< - Array<{ - issueId: number - issueTitle: string - durationHours: number - }> + expectTypeOf(customKeyCollection.toArray).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + issueId: number + issueTitle: string + durationHours: number + }> + > >() // getKey should work with the transformed type - expectTypeOf(customKeyCollection.get(1)).toEqualTypeOf< - | { + expectTypeOf(customKeyCollection.get(1)).toMatchTypeOf< + | OutputWithVirtualKeyed<{ issueId: number issueTitle: string durationHours: number - } + }> | undefined >() }) @@ -231,12 +247,14 @@ describe(`Subquery Types`, () => { }) // Should infer the correct result type - expectTypeOf(liveCollection.toArray).toEqualTypeOf< - Array<{ - id: number - title: string - projectId: number - }> + expectTypeOf(liveCollection.toArray).toMatchTypeOf< + Array< + OutputWithVirtualKeyed<{ + id: number + title: string + projectId: number + }> + > >() }) }) diff --git a/packages/db/tests/query/subquery.test.ts b/packages/db/tests/query/subquery.test.ts index e7049a182..666c2ad83 100644 --- a/packages/db/tests/query/subquery.test.ts +++ b/packages/db/tests/query/subquery.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, test } from 'vitest' import { createLiveQueryCollection, eq, gt } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' -import { mockSyncCollectionOptions } from '../utils.js' +import { mockSyncCollectionOptions, stripVirtualProps } from '../utils.js' // Sample types for subquery testing type Issue = { @@ -270,7 +270,9 @@ function createSubqueryTests(autoIndex: `off` | `eager`): void { expect(result.hours).toBeGreaterThan(10) }) - const sortedResults = results.sort((a, b) => a.key - b.key) + const sortedResults = results + .map((value) => stripVirtualProps(value)) + .sort((a, b) => a.key - b.key) expect(sortedResults).toEqual([ { key: 3, title: `Feature 1`, hours: 12, type: `closed` }, { key: 5, title: `Feature 2`, hours: 15, type: `in_progress` }, diff --git a/packages/db/tests/utils.ts b/packages/db/tests/utils.ts index 49b48db95..4e6c24592 100644 --- a/packages/db/tests/utils.ts +++ b/packages/db/tests/utils.ts @@ -5,6 +5,26 @@ import type { StringCollationConfig, SyncConfig, } from '../src/index.js' +import type { WithVirtualProps } from '../src/virtual-props.js' + +export type OutputWithVirtual< + T extends object, + TKey extends string | number = string | number, +> = WithVirtualProps + +export const stripVirtualProps = | undefined>( + value: T, +) => { + if (!value || typeof value !== `object`) return value + const { + $synced: _synced, + $origin: _origin, + $key: _key, + $collectionId: _collectionId, + ...rest + } = value as Record + return rest as T +} // Index usage tracking utilities export interface IndexUsageStats { diff --git a/packages/electric-db-collection/tests/electric.test-d.ts b/packages/electric-db-collection/tests/electric.test-d.ts index 946e6496a..78014b271 100644 --- a/packages/electric-db-collection/tests/electric.test-d.ts +++ b/packages/electric-db-collection/tests/electric.test-d.ts @@ -17,6 +17,7 @@ import type { InsertMutationFnParams, UpdateMutationFnParams, } from '@tanstack/db' +import type { OutputWithVirtual } from '../../db/tests/utils' describe(`Electric collection type resolution tests`, () => { // Define test types @@ -333,18 +334,22 @@ describe(`Electric collection type resolution tests`, () => { // Test that the query results have the correct inferred types const results = activeUsersQuery.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - id: string - name: string - age: number - email: string - isActive: boolean - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + id: string + name: string + age: number + email: string + isActive: boolean + }> + > >() // Test that the collection itself has the correct type - expectTypeOf(usersCollection.toArray).toEqualTypeOf>() + expectTypeOf(usersCollection.toArray).toMatchTypeOf< + Array> + >() // Test that we can access schema-inferred fields in the query with WHERE conditions const ageFilterQuery = createLiveQueryCollection({ @@ -360,12 +365,14 @@ describe(`Electric collection type resolution tests`, () => { }) const ageFilterResults = ageFilterQuery.toArray - expectTypeOf(ageFilterResults).toEqualTypeOf< - Array<{ - id: string - name: string - age: number - }> + expectTypeOf(ageFilterResults).toMatchTypeOf< + Array< + OutputWithVirtual<{ + id: string + name: string + age: number + }> + > >() // Test that the getKey function has the correct parameter type @@ -421,16 +428,20 @@ describe(`Electric collection type resolution tests`, () => { }) const electricResults = electricQuery.toArray - expectTypeOf(electricResults).toEqualTypeOf< - Array<{ - id: string - name: string - age: number - }> + expectTypeOf(electricResults).toMatchTypeOf< + Array< + OutputWithVirtual<{ + id: string + name: string + age: number + }> + > >() // Test that direct collection has the correct type - expectTypeOf(directCollection.toArray).toEqualTypeOf>() + expectTypeOf(directCollection.toArray).toMatchTypeOf< + Array> + >() // The key insight: electric collection options properly resolve schema types // while direct createCollection with schema doesn't work in query builder diff --git a/packages/electric-db-collection/tests/electric.test.ts b/packages/electric-db-collection/tests/electric.test.ts index 7a8bc7384..f3afbc937 100644 --- a/packages/electric-db-collection/tests/electric.test.ts +++ b/packages/electric-db-collection/tests/electric.test.ts @@ -5,6 +5,7 @@ import { createTransaction, } from '@tanstack/db' import { electricCollectionOptions, isChangeMessage } from '../src/electric' +import { stripVirtualProps } from '../../db/tests/utils' import type { ElectricCollectionUtils } from '../src/electric' import type { Collection, @@ -45,6 +46,14 @@ describe(`Electric Integration`, () => { > let subscriber: (messages: Array>) => void + const stripCollectionState = (state: Map) => + new Map( + Array.from(state.entries(), ([key, value]) => [ + key, + stripVirtualProps(value), + ]), + ) + beforeEach(() => { vi.clearAllMocks() @@ -115,7 +124,7 @@ describe(`Electric Integration`, () => { }, ]) - expect(collection.state).toEqual( + expect(stripCollectionState(collection.state)).toEqual( new Map([[1, { id: 1, name: `Test User` }]]), ) }) @@ -150,7 +159,7 @@ describe(`Electric Integration`, () => { ]) expect(collection.status).toEqual(`ready`) - expect(collection.state).toEqual( + expect(stripCollectionState(collection.state)).toEqual( new Map([ [1, { id: 1, name: `Test User` }], [2, { id: 2, name: `Another User` }], @@ -184,7 +193,7 @@ describe(`Electric Integration`, () => { }, ]) - expect(collection.state).toEqual( + expect(stripCollectionState(collection.state)).toEqual( new Map([[1, { id: 1, name: `Updated User` }]]), ) }) @@ -287,7 +296,10 @@ describe(`Electric Integration`, () => { // The collection should now have the new data expect(collection.state.size).toBe(1) - expect(collection.state.get(3)).toEqual({ id: 3, name: `New User` }) + expect(stripVirtualProps(collection.state.get(3))).toEqual({ + id: 3, + name: `New User`, + }) expect(collection.status).toBe(`ready`) }) @@ -650,7 +662,7 @@ describe(`Electric Integration`, () => { // Verify that the data was added to the collection via the sync process expect(testCollection.has(1)).toBe(true) - expect(testCollection.get(1)).toEqual({ + expect(stripVirtualProps(testCollection.get(1))).toEqual({ id: 1, name: `Direct Persistence User`, }) @@ -2430,7 +2442,10 @@ describe(`Electric Integration`, () => { // Verify snapshot data was applied expect(testCollection.has(2)).toBe(true) - expect(testCollection.get(2)).toEqual({ id: 2, name: `Snapshot User` }) + expect(stripVirtualProps(testCollection.get(2))).toEqual({ + id: 2, + name: `Snapshot User`, + }) }) it(`should not request snapshots when loadSubset is called in eager mode`, async () => { @@ -2723,7 +2738,10 @@ describe(`Electric Integration`, () => { // The snapshot data should be IGNORED because sync already completed expect(testCollection.has(2)).toBe(false) expect(testCollection.size).toBe(1) // Still only the buffered user - expect(testCollection.get(1)).toEqual({ id: 1, name: `Buffered User` }) + expect(stripVirtualProps(testCollection.get(1))).toEqual({ + id: 1, + name: `Buffered User`, + }) }) it(`should default offset to 'now' in on-demand mode when no offset provided`, async () => { @@ -2880,7 +2898,7 @@ describe(`Electric Integration`, () => { // Verify initial data is present expect(testCollection.has(1)).toBe(true) - expect(testCollection.get(1)).toEqual({ + expect(stripVirtualProps(testCollection.get(1))).toEqual({ id: 1, name: `User 1`, updated_at: `2024-01-01T00:00:00Z`, @@ -2910,7 +2928,7 @@ describe(`Electric Integration`, () => { // The row should be updated with the new value expect(testCollection.has(1)).toBe(true) - expect(testCollection.get(1)).toEqual({ + expect(stripVirtualProps(testCollection.get(1))).toEqual({ id: 1, name: `User 1`, updated_at: `2024-01-01T00:00:01Z`, @@ -2950,12 +2968,12 @@ describe(`Electric Integration`, () => { ]) expect(testCollection.size).toBe(2) - expect(testCollection.get(1)).toEqual({ + expect(stripVirtualProps(testCollection.get(1))).toEqual({ id: 1, name: `User 1`, version: 1, }) - expect(testCollection.get(2)).toEqual({ + expect(stripVirtualProps(testCollection.get(2))).toEqual({ id: 2, name: `User 2`, version: 1, @@ -2985,17 +3003,17 @@ describe(`Electric Integration`, () => { // All rows should be present with updated values expect(testCollection.size).toBe(3) - expect(testCollection.get(1)).toEqual({ + expect(stripVirtualProps(testCollection.get(1))).toEqual({ id: 1, name: `User 1`, version: 2, }) - expect(testCollection.get(2)).toEqual({ + expect(stripVirtualProps(testCollection.get(2))).toEqual({ id: 2, name: `User 2`, version: 2, }) - expect(testCollection.get(3)).toEqual({ + expect(stripVirtualProps(testCollection.get(3))).toEqual({ id: 3, name: `User 3`, version: 1, @@ -3052,7 +3070,7 @@ describe(`Electric Integration`, () => { ]) expect(testCollection.has(1)).toBe(true) - expect(testCollection.get(1)).toEqual({ + expect(stripVirtualProps(testCollection.get(1))).toEqual({ id: 1, name: `User 1 After Refetch`, }) @@ -3114,7 +3132,10 @@ describe(`Electric Integration`, () => { ]) expect(testCollection.has(1)).toBe(true) - expect(testCollection.get(1)).toEqual({ id: 1, name: `User 1 Recreated` }) + expect(stripVirtualProps(testCollection.get(1))).toEqual({ + id: 1, + name: `User 1 Recreated`, + }) }) it(`should handle duplicate inserts within the same batch`, () => { @@ -3152,7 +3173,7 @@ describe(`Electric Integration`, () => { // Should have the latest value expect(testCollection.has(1)).toBe(true) - expect(testCollection.get(1)).toEqual({ + expect(stripVirtualProps(testCollection.get(1))).toEqual({ id: 1, name: `User 1`, version: 2, @@ -3219,7 +3240,10 @@ describe(`Electric Integration`, () => { // Now data should be visible after atomic swap expect(testCollection.has(1)).toBe(true) - expect(testCollection.get(1)).toEqual({ id: 1, name: `Test User` }) + expect(stripVirtualProps(testCollection.get(1))).toEqual({ + id: 1, + name: `Test User`, + }) expect(testCollection.status).toBe(`ready`) }) @@ -3252,7 +3276,10 @@ describe(`Electric Integration`, () => { // Data should be committed and collection ready expect(testCollection.has(1)).toBe(true) - expect(testCollection.get(1)).toEqual({ id: 1, name: `Test User` }) + expect(stripVirtualProps(testCollection.get(1))).toEqual({ + id: 1, + name: `Test User`, + }) expect(testCollection.status).toBe(`ready`) }) @@ -3332,7 +3359,10 @@ describe(`Electric Integration`, () => { // Data should be committed (available in state) expect(testCollection.has(1)).toBe(true) - expect(testCollection.get(1)).toEqual({ id: 1, name: `Test User` }) + expect(stripVirtualProps(testCollection.get(1))).toEqual({ + id: 1, + name: `Test User`, + }) // Collection SHOULD be marked as ready in on-demand mode expect(testCollection.status).toBe(`ready`) @@ -3395,7 +3425,10 @@ describe(`Electric Integration`, () => { // Now data should be visible after atomic swap expect(testCollection.has(1)).toBe(true) - expect(testCollection.get(1)).toEqual({ id: 1, name: `Test User` }) + expect(stripVirtualProps(testCollection.get(1))).toEqual({ + id: 1, + name: `Test User`, + }) // And it should be ready expect(testCollection.status).toBe(`ready`) @@ -3615,8 +3648,14 @@ describe(`Electric Integration`, () => { expect(testCollection._state.pendingSyncedTransactions.length).toBe(0) // Verify data is correct (not undefined from orphan transaction) - expect(testCollection.get(3)).toEqual({ id: 3, name: `User 3` }) - expect(testCollection.get(4)).toEqual({ id: 4, name: `User 4` }) + expect(stripVirtualProps(testCollection.get(3))).toEqual({ + id: 3, + name: `User 3`, + }) + expect(stripVirtualProps(testCollection.get(4))).toEqual({ + id: 4, + name: `User 4`, + }) }) it(`should handle must-refetch in progressive mode with txid tracking`, () => { @@ -3765,7 +3804,10 @@ describe(`Electric Integration`, () => { expect(testCollection.status).toBe(`ready`) expect(testCollection.size).toBe(1) expect(testCollection.has(2)).toBe(true) - expect(testCollection.get(2)).toEqual({ id: 2, name: `User 2` }) + expect(stripVirtualProps(testCollection.get(2))).toEqual({ + id: 2, + name: `User 2`, + }) }) it(`should handle multiple batches after must-refetch in progressive mode`, () => { @@ -3948,8 +3990,14 @@ describe(`Electric Integration`, () => { expect(testCollection.status).toBe(`ready`) expect(testCollection.has(1)).toBe(true) expect(testCollection.has(3)).toBe(true) - expect(testCollection.get(1)).toEqual({ id: 1, name: `Updated User` }) - expect(testCollection.get(3)).toEqual({ id: 3, name: `Resynced User` }) + expect(stripVirtualProps(testCollection.get(1))).toEqual({ + id: 1, + name: `Updated User`, + }) + expect(stripVirtualProps(testCollection.get(3))).toEqual({ + id: 3, + name: `Resynced User`, + }) expect(testCollection.size).toBe(2) // Old data should not be present (collection was cleaned) diff --git a/packages/electric-db-collection/tests/tags.test.ts b/packages/electric-db-collection/tests/tags.test.ts index 01dfa2773..3b89a9593 100644 --- a/packages/electric-db-collection/tests/tags.test.ts +++ b/packages/electric-db-collection/tests/tags.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createCollection } from '@tanstack/db' import { electricCollectionOptions } from '../src/electric' +import { stripVirtualProps } from '../../db/tests/utils' import type { ElectricCollectionUtils } from '../src/electric' import type { Collection } from '@tanstack/db' import type { Message, Row } from '@electric-sql/client' @@ -71,6 +72,27 @@ describe(`Electric Tag Tracking and GC`, () => { StandardSchemaV1, Row > + + const stateGetter = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(collection), + `state`, + )?.get + if (stateGetter) { + Object.defineProperty(collection, `state`, { + get: () => { + const state = stateGetter.call(collection) as Map< + string | number, + Row + > + return new Map( + Array.from(state.entries(), ([key, value]) => [ + key, + stripVirtualProps(value), + ]), + ) + }, + }) + } }) it(`should track tags when rows are inserted with tags`, () => { diff --git a/packages/query-db-collection/tests/query.test-d.ts b/packages/query-db-collection/tests/query.test-d.ts index 8481021eb..af6a18b67 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -10,13 +10,14 @@ import { import { QueryClient } from '@tanstack/query-core' import { z } from 'zod' import { queryCollectionOptions } from '../src/query' -import type { QueryCollectionConfig, QueryCollectionUtils } from '../src/query' import type { DeleteMutationFnParams, InsertMutationFnParams, LoadSubsetOptions, UpdateMutationFnParams, } from '@tanstack/db' +import type { OutputWithVirtual } from '../../db/tests/utils' +import type { QueryCollectionConfig, QueryCollectionUtils } from '../src/query' describe(`Query collection type resolution tests`, () => { // Define test types @@ -123,7 +124,9 @@ describe(`Query collection type resolution tests`, () => { const usersCollection = createCollection(queryOptions) // Test that the collection itself has the correct type - expectTypeOf(usersCollection.toArray).toEqualTypeOf>() + expectTypeOf(usersCollection.toArray).toMatchTypeOf< + Array> + >() // Test that the getKey function has the correct parameter type expectTypeOf(queryOptions.getKey).parameters.toEqualTypeOf<[UserType]>() @@ -170,18 +173,22 @@ describe(`Query collection type resolution tests`, () => { // Test that the query results have the correct inferred types const results = activeUsersQuery.toArray - expectTypeOf(results).toEqualTypeOf< - Array<{ - id: string - name: string - age: number - email: string - isActive: boolean - }> + expectTypeOf(results).toMatchTypeOf< + Array< + OutputWithVirtual<{ + id: string + name: string + age: number + email: string + isActive: boolean + }> + > >() // Test that the collection itself has the correct type - expectTypeOf(usersCollection.toArray).toEqualTypeOf>() + expectTypeOf(usersCollection.toArray).toMatchTypeOf< + Array> + >() // Test that we can access schema-inferred fields in the query with WHERE conditions const ageFilterQuery = createLiveQueryCollection({ @@ -197,12 +204,14 @@ describe(`Query collection type resolution tests`, () => { }) const ageFilterResults = ageFilterQuery.toArray - expectTypeOf(ageFilterResults).toEqualTypeOf< - Array<{ - id: string - name: string - age: number - }> + expectTypeOf(ageFilterResults).toMatchTypeOf< + Array< + OutputWithVirtual<{ + id: string + name: string + age: number + }> + > >() // Test that the getKey function has the correct parameter type @@ -319,7 +328,9 @@ describe(`Query collection type resolution tests`, () => { }) const collection = createCollection(options) - expectTypeOf(collection.toArray).toEqualTypeOf>() + expectTypeOf(collection.toArray).toEqualTypeOf< + Array> + >() }) }) diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 0a623de61..e82eca720 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -7,6 +7,7 @@ import { ilike, or, } from '@tanstack/db' +import { stripVirtualProps } from '../../db/tests/utils' import { queryCollectionOptions } from '../src/query' import type { QueryFunctionContext } from '@tanstack/query-core' import type { @@ -95,8 +96,8 @@ describe(`QueryCollection`, () => { // Verify the collection state contains our items expect(collection.size).toBe(initialItems.length) - expect(collection.get(`1`)).toEqual(initialItems[0]) - expect(collection.get(`2`)).toEqual(initialItems[1]) + expect(stripVirtualProps(collection.get(`1`))).toEqual(initialItems[0]) + expect(stripVirtualProps(collection.get(`2`))).toEqual(initialItems[1]) // Verify the synced data expect(collection._state.syncedData.size).toBe(initialItems.length) @@ -138,8 +139,8 @@ describe(`QueryCollection`, () => { // Verify initial state expect(collection.size).toBe(initialItems.length) - expect(collection.get(`1`)).toEqual(initialItems[0]) - expect(collection.get(`2`)).toEqual(initialItems[1]) + expect(stripVirtualProps(collection.get(`1`))).toEqual(initialItems[0]) + expect(stripVirtualProps(collection.get(`2`))).toEqual(initialItems[1]) // Now update the data that will be returned by queryFn // 1. Modify an existing item @@ -164,9 +165,9 @@ describe(`QueryCollection`, () => { expect(collection.has(`2`)).toBe(false) // Verify the final state more thoroughly - expect(collection.get(`1`)).toEqual(updatedItem) - expect(collection.get(`3`)).toEqual(newItem) - expect(collection.get(`2`)).toBeUndefined() + expect(stripVirtualProps(collection.get(`1`))).toEqual(updatedItem) + expect(stripVirtualProps(collection.get(`3`))).toEqual(newItem) + expect(stripVirtualProps(collection.get(`2`))).toBeUndefined() // Now update the data again. const item4 = { id: `4`, name: `Item 4` } @@ -178,7 +179,7 @@ describe(`QueryCollection`, () => { // Verify expected. expect(queryFn).toHaveBeenCalledTimes(3) expect(collection.size).toBe(3) - expect(collection.get(`4`)).toEqual(item4) + expect(stripVirtualProps(collection.get(`4`))).toEqual(item4) }) it(`should handle query errors gracefully`, async () => { @@ -213,7 +214,7 @@ describe(`QueryCollection`, () => { await vi.waitFor(() => { expect(queryFn).toHaveBeenCalledTimes(1) expect(collection.size).toBe(1) - expect(collection.get(`1`)).toEqual(initialItem) + expect(stripVirtualProps(collection.get(`1`))).toEqual(initialItem) }) // Trigger an error by refetching @@ -232,7 +233,7 @@ describe(`QueryCollection`, () => { // The collection should maintain its previous state expect(collection.size).toBe(1) - expect(collection.get(`1`)).toEqual(initialItem) + expect(stripVirtualProps(collection.get(`1`))).toEqual(initialItem) // Clean up the spy consoleErrorSpy.mockRestore() @@ -315,7 +316,7 @@ describe(`QueryCollection`, () => { await vi.waitFor(() => { expect(queryFn).toHaveBeenCalledTimes(1) expect(collection.size).toBe(1) - expect(collection.get(`1`)).toEqual(initialItem) + expect(stripVirtualProps(collection.get(`1`))).toEqual(initialItem) }) // Store the initial state object reference to check if it changes @@ -341,7 +342,11 @@ describe(`QueryCollection`, () => { // Now the state should be updated with the new value const updatedItem = collection.get(`1`) expect(updatedItem).not.toBe(initialStateRef) // Different reference - expect(updatedItem).toEqual({ id: `1`, name: `Test Item`, count: 43 }) // Updated value + expect(stripVirtualProps(updatedItem)).toEqual({ + id: `1`, + name: `Test Item`, + count: 43, + }) // Updated value consoleSpy.mockRestore() }) @@ -385,8 +390,8 @@ describe(`QueryCollection`, () => { // Verify items are stored with the custom keys expect(collection.has(`item1`)).toBe(true) expect(collection.has(`item2`)).toBe(true) - expect(collection.get(`item1`)).toEqual(items[0]) - expect(collection.get(`item2`)).toEqual(items[1]) + expect(stripVirtualProps(collection.get(`item1`))).toEqual(items[0]) + expect(stripVirtualProps(collection.get(`item2`))).toEqual(items[1]) // Now update an item and add a new one const updatedItems = [ @@ -416,8 +421,8 @@ describe(`QueryCollection`, () => { expect(collection.has(`item1`)).toBe(true) expect(collection.has(`item2`)).toBe(false) // Removed expect(collection.has(`item3`)).toBe(true) // Added - expect(collection.get(`item1`)).toEqual(updatedItems[0]) - expect(collection.get(`item3`)).toEqual(updatedItems[1]) + expect(stripVirtualProps(collection.get(`item1`))).toEqual(updatedItems[0]) + expect(stripVirtualProps(collection.get(`item3`))).toEqual(updatedItems[1]) }) it(`should pass meta property to queryFn context`, async () => { @@ -617,8 +622,12 @@ describe(`QueryCollection`, () => { }) expect(collection.size).toBe(initialMetaData.data.length) - expect(collection.get(`1`)).toEqual(initialMetaData.data[0]) - expect(collection.get(`2`)).toEqual(initialMetaData.data[1]) + expect(stripVirtualProps(collection.get(`1`))).toEqual( + initialMetaData.data[0], + ) + expect(stripVirtualProps(collection.get(`2`))).toEqual( + initialMetaData.data[1], + ) }) it(`Throws error if select returns non array`, async () => { @@ -725,7 +734,7 @@ describe(`QueryCollection`, () => { // Verify the item was inserted expect(collection.size).toBe(3) - expect(collection.get(`3`)).toEqual(newItem) + expect(stripVirtualProps(collection.get(`3`))).toEqual(newItem) // Wait a tick to allow any async error handlers to run await flushPromises() @@ -774,7 +783,7 @@ describe(`QueryCollection`, () => { // Verify the item was inserted expect(collection.size).toBe(3) - expect(collection.get(`3`)).toEqual(newItem) + expect(stripVirtualProps(collection.get(`3`))).toEqual(newItem) // Test upsert for existing item collection.utils.writeUpsert({ id: `1`, name: `Updated First Item` }) @@ -1391,8 +1400,14 @@ describe(`QueryCollection`, () => { expect(collection.size).toBe(2) }) - expect(collection.get(`1`)).toEqual({ id: `1`, name: `Updated Item 1` }) - expect(collection.get(`2`)).toEqual({ id: `2`, name: `Item 2` }) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Updated Item 1`, + }) + expect(stripVirtualProps(collection.get(`2`))).toEqual({ + id: `2`, + name: `Item 2`, + }) }) it(`should handle concurrent query operations`, async () => { @@ -1429,7 +1444,10 @@ describe(`QueryCollection`, () => { // Collection should remain in a consistent state expect(collection.size).toBe(1) - expect(collection.get(`1`)).toEqual({ id: `1`, name: `Item 1` }) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Item 1`, + }) }) it(`should handle query state transitions properly`, async () => { @@ -1719,7 +1737,7 @@ describe(`QueryCollection`, () => { collection.utils.writeInsert(newItem) expect(collection.size).toBe(3) - expect(collection.get(`3`)).toEqual(newItem) + expect(stripVirtualProps(collection.get(`3`))).toEqual(newItem) // Test writeUpdate collection.utils.writeUpdate({ id: `1`, name: `Updated Item 1` }) @@ -1743,7 +1761,7 @@ describe(`QueryCollection`, () => { collection.utils.writeUpsert({ id: `4`, name: `New Item 4`, value: 40 }) expect(collection.size).toBe(4) - expect(collection.get(`4`)).toEqual({ + expect(stripVirtualProps(collection.get(`4`))).toEqual({ id: `4`, name: `New Item 4`, value: 40, @@ -1993,7 +2011,9 @@ describe(`QueryCollection`, () => { // Verify cache and collection are in sync expect(cacheAfterBatch.length).toBe(collection.size) - expect(new Set(cacheAfterBatch)).toEqual(new Set(collection.toArray)) + expect(new Set(cacheAfterBatch)).toEqual( + new Set(collection.toArray.map((item) => stripVirtualProps(item))), + ) }) it(`should maintain cache consistency during error scenarios`, async () => { @@ -2532,9 +2552,9 @@ describe(`QueryCollection`, () => { ) expect(collection.status).toBe(`ready`) expect(collection.size).toBe(2) - expect(Array.from(collection.values())).toEqual( - expect.arrayContaining(initialItems), - ) + expect( + Array.from(collection.values()).map((item) => stripVirtualProps(item)), + ).toEqual(expect.arrayContaining(initialItems)) }) describe(`subscriber count tracking and auto-subscription`, () => { @@ -2957,7 +2977,7 @@ describe(`QueryCollection`, () => { expect(collection.utils.lastError).toBeUndefined() expect(collection.utils.isError).toBe(false) expect(collection.utils.errorCount).toBe(0) - expect(collection.get(`1`)).toEqual(updatedData[0]) + expect(stripVirtualProps(collection.get(`1`))).toEqual(updatedData[0]) }) }) @@ -2990,7 +3010,7 @@ describe(`QueryCollection`, () => { expect(collection.utils.errorCount).toBe(0) await vi.waitFor(() => { - expect(collection.get(`1`)).toEqual(recoveryData[0]) + expect(stripVirtualProps(collection.get(`1`))).toEqual(recoveryData[0]) }) // Refetch on rejection should throw an error @@ -3031,14 +3051,14 @@ describe(`QueryCollection`, () => { // Collection operations still work with cached data expect(collection.size).toBe(2) - expect(collection.get(`1`)).toEqual(initialData[0]) - expect(collection.get(`2`)).toEqual(initialData[1]) + expect(stripVirtualProps(collection.get(`1`))).toEqual(initialData[0]) + expect(stripVirtualProps(collection.get(`2`))).toEqual(initialData[1]) // Manual write operations work and clear error state const newItem = { id: `3`, name: `Manual Item` } collection.utils.writeInsert(newItem) expect(collection.size).toBe(3) - expect(collection.get(`3`)).toEqual(newItem) + expect(stripVirtualProps(collection.get(`3`))).toEqual(newItem) await flushPromises() @@ -3237,8 +3257,8 @@ describe(`QueryCollection`, () => { expect(collection.status).toBe(`ready`) expect(queryFn).toHaveBeenCalledTimes(1) expect(collection.size).toBe(items.length) - expect(collection.get(`1`)).toEqual(items[0]) - expect(collection.get(`2`)).toEqual(items[1]) + expect(stripVirtualProps(collection.get(`1`))).toEqual(items[0]) + expect(stripVirtualProps(collection.get(`2`))).toEqual(items[1]) }) it(`should not call queryFn multiple times if preload() is called concurrently`, async () => { @@ -3360,8 +3380,8 @@ describe(`QueryCollection`, () => { }) expect(queryFn).toHaveBeenCalledTimes(1) - expect(collection.get(`1`)).toEqual(items[0]) - expect(collection.get(`2`)).toEqual(items[1]) + expect(stripVirtualProps(collection.get(`1`))).toEqual(items[0]) + expect(stripVirtualProps(collection.get(`2`))).toEqual(items[1]) }) }) @@ -3538,9 +3558,9 @@ describe(`QueryCollection`, () => { // Wait for initial data to load await vi.waitFor(() => { expect(collection.size).toBe(3) - expect(collection.get(`1`)).toEqual(items[0]) - expect(collection.get(`2`)).toEqual(items[1]) - expect(collection.get(`3`)).toEqual(items[2]) + expect(stripVirtualProps(collection.get(`1`))).toEqual(items[0]) + expect(stripVirtualProps(collection.get(`2`))).toEqual(items[1]) + expect(stripVirtualProps(collection.get(`3`))).toEqual(items[2]) }) // Verify all items are in the collection diff --git a/packages/react-db/tests/useLiveQuery.test-d.tsx b/packages/react-db/tests/useLiveQuery.test-d.tsx index 41162698c..43747284d 100644 --- a/packages/react-db/tests/useLiveQuery.test-d.tsx +++ b/packages/react-db/tests/useLiveQuery.test-d.tsx @@ -8,6 +8,7 @@ import { liveQueryCollectionOptions, } from '../../db/src/query/index' import { useLiveQuery } from '../src/useLiveQuery' +import type { OutputWithVirtual } from '../../db/tests/utils' import type { SingleResult } from '../../db/src/types' type Person = { @@ -38,7 +39,9 @@ describe(`useLiveQuery type assertions`, () => { ) }) - expectTypeOf(result.current.data).toEqualTypeOf() + expectTypeOf(result.current.data).toMatchTypeOf< + OutputWithVirtual | undefined + >() }) it(`should type findOne config object to return a single row`, () => { @@ -60,7 +63,9 @@ describe(`useLiveQuery type assertions`, () => { }) }) - expectTypeOf(result.current.data).toEqualTypeOf() + expectTypeOf(result.current.data).toMatchTypeOf< + OutputWithVirtual | undefined + >() }) it(`should type findOne collection using liveQueryCollectionOptions to return a single row`, () => { @@ -88,7 +93,9 @@ describe(`useLiveQuery type assertions`, () => { return useLiveQuery(liveQueryCollection) }) - expectTypeOf(result.current.data).toEqualTypeOf() + expectTypeOf(result.current.data).toMatchTypeOf< + OutputWithVirtual | undefined + >() }) it(`should type findOne collection using createLiveQueryCollection to return a single row`, () => { @@ -114,6 +121,8 @@ describe(`useLiveQuery type assertions`, () => { return useLiveQuery(liveQueryCollection) }) - expectTypeOf(result.current.data).toEqualTypeOf() + expectTypeOf(result.current.data).toMatchTypeOf< + OutputWithVirtual | undefined + >() }) }) diff --git a/packages/rxdb-db-collection/tests/rxdb.test.ts b/packages/rxdb-db-collection/tests/rxdb.test.ts index 03f470d88..c3ff49a76 100644 --- a/packages/rxdb-db-collection/tests/rxdb.test.ts +++ b/packages/rxdb-db-collection/tests/rxdb.test.ts @@ -8,6 +8,7 @@ import { import { RxDBDevModePlugin } from 'rxdb/plugins/dev-mode' import { getRxStorageMemory } from 'rxdb/plugins/storage-memory' import { wrappedValidateAjvStorage } from 'rxdb/plugins/validate-ajv' +import { stripVirtualProps } from '../../db/tests/utils' import { OPEN_RXDB_SUBSCRIPTIONS, rxdbCollectionOptions } from '../src/rxdb' import type { RxCollection } from 'rxdb/plugins/core' import type { RxDBCollectionConfig } from '../src/rxdb' @@ -109,8 +110,8 @@ describe(`RxDB Integration`, () => { // Verify the collection state contains our items expect(collection.size).toBe(initialItems.length) - expect(collection.get(`1`)).toEqual(initialItems[0]) - expect(collection.get(`2`)).toEqual(initialItems[1]) + expect(stripVirtualProps(collection.get(`1`))).toEqual(initialItems[0]) + expect(stripVirtualProps(collection.get(`2`))).toEqual(initialItems[1]) // Verify the synced data expect(collection._state.syncedData.size).toBe(initialItems.length) @@ -130,10 +131,22 @@ describe(`RxDB Integration`, () => { expect(collection._state.syncedData.size).toBe(docsAmount) // Spot-check a few positions - expect(collection.get(`1`)).toEqual({ id: `1`, name: `Item 1` }) - expect(collection.get(`10`)).toEqual({ id: `10`, name: `Item 10` }) - expect(collection.get(`11`)).toEqual({ id: `11`, name: `Item 11` }) - expect(collection.get(`25`)).toEqual({ id: `25`, name: `Item 25` }) + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + name: `Item 1`, + }) + expect(stripVirtualProps(collection.get(`10`))).toEqual({ + id: `10`, + name: `Item 10`, + }) + expect(stripVirtualProps(collection.get(`11`))).toEqual({ + id: `11`, + name: `Item 11`, + }) + expect(stripVirtualProps(collection.get(`25`))).toEqual({ + id: `25`, + name: `Item 25`, + }) // Ensure no gaps for (let i = 1; i <= docsAmount; i++) { @@ -151,15 +164,15 @@ describe(`RxDB Integration`, () => { // inserts const doc = await rxCollection.insert({ id: `3`, name: `inserted` }) - expect(collection.get(`3`).name).toEqual(`inserted`) + expect(stripVirtualProps(collection.get(`3`))?.name).toEqual(`inserted`) // updates await doc.getLatest().patch({ name: `updated` }) - expect(collection.get(`3`).name).toEqual(`updated`) + expect(stripVirtualProps(collection.get(`3`))?.name).toEqual(`updated`) // deletes await doc.getLatest().remove() - expect(collection.get(`3`)).toEqual(undefined) + expect(stripVirtualProps(collection.get(`3`))).toEqual(undefined) await db.remove() }) @@ -180,7 +193,7 @@ describe(`RxDB Integration`, () => { collection.update(`3`, (d) => { d.name = `updated` }) - expect(collection.get(`3`).name).toEqual(`updated`) + expect(stripVirtualProps(collection.get(`3`))?.name).toEqual(`updated`) await collection.stateWhenReady() await rxCollection.database.requestIdlePromise() doc = await rxCollection.findOne(`3`).exec(true) @@ -228,7 +241,7 @@ describe(`RxDB Integration`, () => { const subscription = collection.subscribeChanges(() => {}) await collection.toArrayWhenReady() - expect(collection.get(`3`).name).toEqual(`Item 3`) + expect(stripVirtualProps(collection.get(`3`))?.name).toEqual(`Item 3`) subscription.unsubscribe() await db.remove() @@ -305,7 +318,7 @@ describe(`RxDB Integration`, () => { }) await tx.isPersisted.promise }).rejects.toThrow(/schema validation error/) - expect(collection.get(`2`).name).toBe(`Item 2`) + expect(stripVirtualProps(collection.get(`2`))?.name).toBe(`Item 2`) await db.remove() }) diff --git a/packages/trailbase-db-collection/tests/trailbase.test.ts b/packages/trailbase-db-collection/tests/trailbase.test.ts index f59605394..7e42efd52 100644 --- a/packages/trailbase-db-collection/tests/trailbase.test.ts +++ b/packages/trailbase-db-collection/tests/trailbase.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { createCollection } from '@tanstack/db' import { trailBaseCollectionOptions } from '../src/trailbase' +import { stripVirtualProps } from '../../db/tests/utils' import type { CreateOperation, DeleteOperation, @@ -24,6 +25,14 @@ type Data = { data: string } +const stripState = (state: Map) => + new Map( + Array.from(state.entries(), ([key, value]) => [ + key, + stripVirtualProps(value), + ]), + ) + class MockRecordApi implements RecordApi { list = vi.fn( (_opts?: { @@ -144,7 +153,9 @@ describe(`TrailBase Integration`, () => { // Await initial fetch and assert state. await listPromise - expect(collection.state).toEqual(new Map(records.map((d) => [d.id, d]))) + expect(stripState(collection.state)).toEqual( + new Map(records.map((d) => [d.id, d])), + ) // Inject an update event and assert state. const updatedRecord: Data = { @@ -154,7 +165,7 @@ describe(`TrailBase Integration`, () => { await injectEvent({ Update: updatedRecord }) - expect(collection.state).toEqual( + expect(stripState(collection.state)).toEqual( new Map([updatedRecord].map((d) => [d.id, d])), ) @@ -183,7 +194,7 @@ describe(`TrailBase Integration`, () => { const collection = createCollection(options) // Await initial fetch and assert state. - expect(collection.state).toEqual(new Map([])) + expect(stripState(collection.state)).toEqual(new Map([])) // Inject an update event and assert state. const data: Data = { @@ -196,13 +207,15 @@ describe(`TrailBase Integration`, () => { Insert: data, }) - expect(collection.state).toEqual(new Map([data].map((d) => [d.id, d]))) + expect(stripState(collection.state)).toEqual( + new Map([data].map((d) => [d.id, d])), + ) await injectEvent({ Delete: data, }) - expect(collection.state).toEqual(new Map([])) + expect(stripState(collection.state)).toEqual(new Map([])) stream.writable.close() }) @@ -234,7 +247,7 @@ describe(`TrailBase Integration`, () => { const collection = createCollection(options) // Await initial fetch and assert state. - expect(collection.state).toEqual(new Map([])) + expect(stripState(collection.state)).toEqual(new Map([])) const data: Data = { id: 42, @@ -246,7 +259,7 @@ describe(`TrailBase Integration`, () => { expect(createBulkMock).toHaveBeenCalledOnce() - expect(collection.state).toEqual(new Map([[data.id, data]])) + expect(stripState(collection.state)).toEqual(new Map([[data.id, data]])) const updatedData: Data = { ...data, @@ -271,7 +284,9 @@ describe(`TrailBase Integration`, () => { expect(updateMock).toHaveBeenCalledOnce() - expect(collection.state).toEqual(new Map([[updatedData.id, updatedData]])) + expect(stripState(collection.state)).toEqual( + new Map([[updatedData.id, updatedData]]), + ) const deleteMock = recordApi.delete.mockImplementation( (_id: string | number) => { @@ -288,6 +303,6 @@ describe(`TrailBase Integration`, () => { expect(deleteMock).toHaveBeenCalledOnce() - expect(collection.state).toEqual(new Map([])) + expect(stripState(collection.state)).toEqual(new Map([])) }) }) diff --git a/packages/vue-db/tests/useLiveQuery.test-d.ts b/packages/vue-db/tests/useLiveQuery.test-d.ts index df54ec2be..8c1d5077e 100644 --- a/packages/vue-db/tests/useLiveQuery.test-d.ts +++ b/packages/vue-db/tests/useLiveQuery.test-d.ts @@ -7,6 +7,7 @@ import { liveQueryCollectionOptions, } from '../../db/src/query/index' import { useLiveQuery } from '../src/useLiveQuery' +import type { OutputWithVirtual } from '../../db/tests/utils' import type { SingleResult } from '../../db/src/types' type Person = { @@ -36,7 +37,9 @@ describe(`useLiveQuery type assertions`, () => { ) // BUG: Currently returns ComputedRef> but should be ComputedRef - expectTypeOf(data.value).toEqualTypeOf() + expectTypeOf(data.value).toMatchTypeOf< + OutputWithVirtual | undefined + >() }) it(`should type findOne config object to return a single row`, () => { @@ -57,7 +60,9 @@ describe(`useLiveQuery type assertions`, () => { }) // BUG: Currently returns ComputedRef> but should be ComputedRef - expectTypeOf(data.value).toEqualTypeOf() + expectTypeOf(data.value).toMatchTypeOf< + OutputWithVirtual | undefined + >() }) it(`should type findOne collection using liveQueryCollectionOptions to return a single row`, () => { @@ -84,7 +89,9 @@ describe(`useLiveQuery type assertions`, () => { const { data } = useLiveQuery(liveQueryCollection) // BUG: Currently returns ComputedRef> but should be ComputedRef - expectTypeOf(data.value).toEqualTypeOf() + expectTypeOf(data.value).toMatchTypeOf< + OutputWithVirtual | undefined + >() }) it(`should type findOne collection using createLiveQueryCollection to return a single row`, () => { @@ -109,7 +116,9 @@ describe(`useLiveQuery type assertions`, () => { const { data } = useLiveQuery(liveQueryCollection) // BUG: Currently returns ComputedRef> but should be ComputedRef - expectTypeOf(data.value).toEqualTypeOf() + expectTypeOf(data.value).toMatchTypeOf< + OutputWithVirtual | undefined + >() }) it(`should type regular query to return an array`, () => { @@ -132,8 +141,8 @@ describe(`useLiveQuery type assertions`, () => { ) // Regular queries should return an array - expectTypeOf(data.value).toEqualTypeOf< - Array<{ id: string; name: string }> + expectTypeOf(data.value).toMatchTypeOf< + Array> >() }) })