From 157316c523ba71bd8a17c570234f21cf410d3464 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 11:57:28 +0000 Subject: [PATCH 1/3] feat: add virtual properties core support --- packages/db/src/collection/changes.ts | 22 +- packages/db/src/collection/index.ts | 1 + packages/db/src/collection/state.ts | 103 ++++++++ packages/db/src/index.ts | 9 + packages/db/src/local-only.ts | 6 + packages/db/src/query/builder/ref-proxy.ts | 22 +- packages/db/src/query/builder/types.ts | 33 ++- packages/db/src/virtual-props.ts | 289 +++++++++++++++++++++ 8 files changed, 479 insertions(+), 6 deletions(-) create mode 100644 packages/db/src/virtual-props.ts diff --git a/packages/db/src/collection/changes.ts b/packages/db/src/collection/changes.ts index 389d6ba17..5d1ca8710 100644 --- a/packages/db/src/collection/changes.ts +++ b/packages/db/src/collection/changes.ts @@ -10,6 +10,7 @@ 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' export class CollectionChangesManager< TOutput extends object = Record, @@ -21,6 +22,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 +39,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 +59,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 { + return this.state.enrichChangeMessage(change) + } + /** * Emit events either immediately or batch them for later emission */ @@ -87,9 +101,15 @@ export class CollectionChangesManager< return } + // Enrich all change messages with virtual properties + // This uses the "add-if-missing" pattern to preserve pass-through semantics + const enrichedEvents = eventsToEmit.map((change) => + this.enrichChangeWithVirtualProps(change), + ) + // Emit to all listeners for (const subscription of this.changeSubscriptions) { - subscription.emitEvents(eventsToEmit) + subscription.emitEvents(enrichedEvents) } } diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 39f59ed73..0b47896ce 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -340,6 +340,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 diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index b873610f6..56d0da941 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -1,5 +1,11 @@ import { deepEquals } from '../utils' import { SortedMap } from '../SortedMap' +import { + + + enrichRowWithVirtualProps +} from '../virtual-props.js' +import type {VirtualOrigin, WithVirtualProps} from '../virtual-props.js'; import type { Transaction } from '../transactions' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { @@ -58,6 +64,24 @@ export class CollectionStateManager< public optimisticUpserts = new Map() public optimisticDeletes = 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() + // Cached size for performance public size = 0 @@ -96,6 +120,66 @@ 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 { + 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 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 { + return enrichRowWithVirtualProps( + row, + key, + this.collection.id, + () => this.isRowSynced(key), + () => this.getRowOrigin(key), + ) + } + + /** + * 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 (virtual derived state) */ @@ -259,6 +343,9 @@ export class CollectionStateManager< for (const transaction of activeTransactions) { for (const mutation of transaction.mutations) { if (this.isThisCollection(mutation.collection) && mutation.optimistic) { + // Track that this key has pending local changes for $origin tracking + this.pendingLocalChanges.add(mutation.key) + switch (mutation.type) { case `insert`: case `update`: @@ -582,10 +669,18 @@ export class CollectionStateManager< break } + // Determine origin: 'local' if this key had pending local changes, 'remote' otherwise + const origin: VirtualOrigin = this.pendingLocalChanges.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) break case `update`: { if (rowUpdateMode === `partial`) { @@ -598,10 +693,16 @@ 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) break } case `delete`: this.syncedData.delete(key) + // Clean up origin and pending tracking for deleted rows + this.rowOrigins.delete(key) + this.pendingLocalChanges.delete(key) break } } @@ -908,6 +1009,8 @@ export class CollectionStateManager< this.syncedMetadata.clear() this.optimisticUpserts.clear() this.optimisticDeletes.clear() + this.rowOrigins.clear() + this.pendingLocalChanges.clear() this.size = 0 this.pendingSyncedTransactions = [] this.syncedKeys.clear() 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/local-only.ts b/packages/db/src/local-only.ts index 68168ae02..108b3d689 100644 --- a/packages/db/src/local-only.ts +++ b/packages/db/src/local-only.ts @@ -315,6 +315,12 @@ function createLocalOnlySync( // 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..ca378a7a5 100644 --- a/packages/db/src/query/builder/ref-proxy.ts +++ b/packages/db/src/query/builder/ref-proxy.ts @@ -11,18 +11,32 @@ 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 = { + 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 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..34ab74cd3 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 } from '../../virtual-props.js' /** * Context - The central state container for query builder operations @@ -471,6 +472,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 +509,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 +544,7 @@ export type Ref = { IsPlainObject extends true ? Ref : RefLeaf -} & RefLeaf +} & RefLeaf & VirtualPropsRef /** * Ref - The user-facing ref type with clean IDE display diff --git a/packages/db/src/virtual-props.ts b/packages/db/src/virtual-props.ts new file mode 100644 index 000000000..b71276dcd --- /dev/null +++ b/packages/db/src/virtual-props.ts @@ -0,0 +1,289 @@ +/** + * 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 { + /** + * 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) +} From 2bf221612619d9c575a6f5826e06273f9d0a5013 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 11:59:28 +0000 Subject: [PATCH 2/3] test: update expectations for virtual props --- packages/db/tests/collection-getters.test.ts | 58 +++- .../collection-subscribe-changes.test.ts | 326 +++++++++++++++--- packages/db/tests/collection-truncate.test.ts | 69 +++- packages/db/tests/collection.test-d.ts | 31 +- packages/db/tests/collection.test.ts | 149 +++++--- .../tests/query/live-query-collection.test.ts | 32 +- .../tests/query/query-while-syncing.test.ts | 28 +- packages/db/tests/query/select-spread.test.ts | 26 +- .../query-db-collection/tests/query.test-d.ts | 24 +- 9 files changed, 588 insertions(+), 155 deletions(-) diff --git a/packages/db/tests/collection-getters.test.ts b/packages/db/tests/collection-getters.test.ts index f5f8dda41..bd12164d9 100644 --- a/packages/db/tests/collection-getters.test.ts +++ b/packages/db/tests/collection-getters.test.ts @@ -6,6 +6,29 @@ import type { SyncConfig } from '../src/types' type Item = { id: string; name: string } +const stripVirtualProps = | undefined>( + value: T, +) => { + if (!value || typeof value !== `object`) return value + const { + $synced: _synced, + $origin: _origin, + $key: _key, + $collectionId: _collectionId, + ...rest + } = value + return rest as T +} + +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 +65,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 +279,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 +291,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 +303,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 +320,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 +329,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 +341,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 +352,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 +369,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 +383,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 +400,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 +426,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 +479,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 +488,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 +499,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) }) diff --git a/packages/db/tests/collection-subscribe-changes.test.ts b/packages/db/tests/collection-subscribe-changes.test.ts index 02e987e1e..f62347360 100644 --- a/packages/db/tests/collection-subscribe-changes.test.ts +++ b/packages/db/tests/collection-subscribe-changes.test.ts @@ -1,6 +1,8 @@ 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 { createLocalOnlyCollection } from '../src/local-only.js' import { createTransaction } from '../src/transactions' import { and, eq, gt } from '../src/query/builder/functions' import { PropRef } from '../src/query/ir' @@ -15,6 +17,32 @@ import type { // Helper function to wait for changes to be processed const waitForChanges = () => new Promise((resolve) => setTimeout(resolve, 10)) +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 +} + +const normalizeChange = >( + change: ChangeMessage, +): ChangeMessage => ({ + ...change, + value: stripVirtualProps(change.value), + previousValue: change.previousValue + ? (stripVirtualProps(change.previousValue)) + : undefined, +}) + +const normalizeChanges = >( + changes: Array>, +) => changes.map(normalizeChange) + describe(`Collection.subscribeChanges`, () => { it(`should emit initial collection state as insert changes`, () => { const callback = vi.fn() @@ -160,7 +188,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 +216,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 +309,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 +347,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 +433,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 +448,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`, @@ -442,7 +476,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, @@ -486,7 +520,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() @@ -856,8 +893,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 +918,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 +998,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 +1017,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 +1126,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 +1240,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 () => { @@ -1304,7 +1350,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 +1410,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 +1460,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 +1560,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 +1630,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, @@ -1743,12 +1795,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 +1819,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 +1842,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 +1861,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 +1916,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 +2111,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 +2137,194 @@ 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> = [] + + 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 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 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 mark local-only collections as synced with local origin`, async () => { + const collection = createLocalOnlyCollection<{ id: string; value: string }>({ + id: `virtual-props-local-only`, + getKey: (item) => item.id, + }) + + const changes: Array> = [] + const subscription = collection.subscribeChanges( + (events) => 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..2f7b113ef 100644 --- a/packages/db/tests/collection-truncate.test.ts +++ b/packages/db/tests/collection-truncate.test.ts @@ -2,6 +2,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createCollection } from '../src/collection/index.js' import type { LoadSubsetOptions, SyncConfig } from '../src/types' +const stripVirtualProps = | undefined>( + value: T, +) => { + if (!value || typeof value !== `object`) return value + const { + $synced: _synced, + $origin: _origin, + $key: _key, + $collectionId: _collectionId, + ...rest + } = value + return rest as T +} + +const getStateValue = < + T extends Record, + TKey extends string | number, +>( + collection: { state: Map }, + key: TKey, +) => stripVirtualProps(collection.state.get(key)) + describe(`Collection truncate operations`, () => { beforeEach(() => { vi.useFakeTimers() @@ -75,9 +97,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 +160,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 () => { @@ -194,7 +216,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( @@ -329,9 +351,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 +425,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 +518,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 +529,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 +546,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 +657,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 +668,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 +735,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 () => { @@ -820,8 +851,8 @@ 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 +1156,8 @@ 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..93d37f31c 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 { WithVirtualProps } from '../src/virtual-props.js' import type { OperationConfig } from '../src/types' import type { StandardSchemaV1 } from '@standard-schema/spec' @@ -58,7 +59,11 @@ describe(`Collection type resolution tests`, () => { }) type SchemaType = StandardSchemaV1.InferOutput - type ItemOf = T extends Array ? U : T +type ItemOf = T extends Array ? U : T +type OutputWithVirtual = WithVirtualProps< + T, + TKey +> it(`should use explicit type when provided without schema`, () => { const _collection = createCollection({ @@ -69,7 +74,9 @@ describe(`Collection type resolution tests`, () => { sync: { sync: () => {} }, }) - expectTypeOf(_collection.toArray).toEqualTypeOf>() + expectTypeOf(_collection.toArray).toEqualTypeOf< + Array> + >() type Key = Parameters[0] expectTypeOf().toEqualTypeOf() @@ -88,7 +95,9 @@ describe(`Collection type resolution tests`, () => { schema: testSchema, }) - expectTypeOf(_collection.toArray).toEqualTypeOf>() + expectTypeOf(_collection.toArray).toEqualTypeOf< + Array> + >() type Key = Parameters[0] expectTypeOf().toEqualTypeOf() @@ -214,7 +223,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 +267,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 +315,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 +363,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..795d19a4b 100644 --- a/packages/db/tests/collection.test.ts +++ b/packages/db/tests/collection.test.ts @@ -18,6 +18,28 @@ import { } from './utils' import type { ChangeMessage, MutationFn, PendingMutation } from '../src/types' +const stripVirtualProps = | undefined>( + value: T, +) => { + if (!value || typeof value !== `object`) return value + const { + $synced: _synced, + $origin: _origin, + $key: _key, + $collectionId: _collectionId, + ...rest + } = value + return rest as T +} + +const getStateValue = < + T extends Record, + TKey extends string | number, +>( + collection: { state: Map }, + key: TKey, +) => stripVirtualProps(collection.state.get(key)) + 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 +69,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(() => { @@ -213,9 +237,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( @@ -272,7 +299,7 @@ describe(`Collection`, () => { // 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 +314,9 @@ 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 +329,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 +349,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 +380,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 +406,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`, @@ -609,8 +639,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 +1020,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 +1035,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 +1043,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 +1112,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 +1128,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 +1145,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 +1226,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 +1262,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 +1308,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 +1357,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 +1390,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 +1468,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 +1528,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 +1549,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 +1567,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 +1616,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 +1627,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 +1638,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 +1677,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 +1691,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/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 3c73efa93..e33273ae4 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -31,6 +31,20 @@ const sampleUsers: Array = [ { id: 3, name: `Charlie`, active: false }, ] +const stripVirtualProps = | undefined>( + value: T, +) => { + if (!value || typeof value !== `object`) return value + const { + $synced: _synced, + $origin: _origin, + $key: _key, + $collectionId: _collectionId, + ...rest + } = value + return rest as T +} + function createUsersCollection() { return createCollection( mockSyncCollectionOptions({ @@ -417,8 +431,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 +452,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/query-while-syncing.test.ts b/packages/db/tests/query/query-while-syncing.test.ts index 5616e08a8..abcf4b8c1 100644 --- a/packages/db/tests/query/query-while-syncing.test.ts +++ b/packages/db/tests/query/query-while-syncing.test.ts @@ -31,6 +31,20 @@ const sampleDepartments: Array = [ { id: 3, name: `Marketing`, budget: 60000 }, ] +const stripVirtualProps = | undefined>( + value: T, +) => { + if (!value || typeof value !== `object`) return value + const { + $synced: _synced, + $origin: _origin, + $key: _key, + $collectionId: _collectionId, + ...rest + } = value + return rest as T +} + describe(`Query while syncing`, () => { beforeEach(() => { vi.useFakeTimers() @@ -122,7 +136,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` }, @@ -335,7 +349,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 +381,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 +457,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 +477,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 +762,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 +929,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/select-spread.test.ts b/packages/db/tests/query/select-spread.test.ts index 30665f0fe..b4f0e0146 100644 --- a/packages/db/tests/query/select-spread.test.ts +++ b/packages/db/tests/query/select-spread.test.ts @@ -16,6 +16,20 @@ const initialMessages: Array = [ { id: 2, text: `world`, user: `kim` }, ] +const stripVirtualProps = | undefined>( + value: T, +) => { + if (!value || typeof value !== `object`) return value + const { + $synced: _synced, + $origin: _origin, + $key: _key, + $collectionId: _collectionId, + ...rest + } = value + return rest as T +} + function createMessagesCollection() { return createCollection( mockSyncCollectionOptions({ @@ -101,9 +115,9 @@ 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 +191,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 +208,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/query-db-collection/tests/query.test-d.ts b/packages/query-db-collection/tests/query.test-d.ts index 8481021eb..6472cb4e5 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -10,13 +10,17 @@ 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, +import type { DeleteMutationFnParams, InsertMutationFnParams, LoadSubsetOptions, UpdateMutationFnParams, -} from '@tanstack/db' + WithVirtualProps } from '@tanstack/db' +import type { QueryCollectionConfig, QueryCollectionUtils } from '../src/query' + +type OutputWithVirtual = WithVirtualProps< + T, + TKey +> describe(`Query collection type resolution tests`, () => { // Define test types @@ -123,7 +127,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).toEqualTypeOf< + Array> + >() // Test that the getKey function has the correct parameter type expectTypeOf(queryOptions.getKey).parameters.toEqualTypeOf<[UserType]>() @@ -181,7 +187,9 @@ describe(`Query collection type resolution tests`, () => { >() // Test that the collection itself has the correct type - expectTypeOf(usersCollection.toArray).toEqualTypeOf>() + expectTypeOf(usersCollection.toArray).toEqualTypeOf< + Array> + >() // Test that we can access schema-inferred fields in the query with WHERE conditions const ageFilterQuery = createLiveQueryCollection({ @@ -319,7 +327,9 @@ describe(`Query collection type resolution tests`, () => { }) const collection = createCollection(options) - expectTypeOf(collection.toArray).toEqualTypeOf>() + expectTypeOf(collection.toArray).toEqualTypeOf< + Array> + >() }) }) From 955beedbdcd912b640860b076a7c61bba69377f3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:01:45 +0000 Subject: [PATCH 3/3] ci: apply automated fixes --- packages/db/src/collection/state.ts | 8 ++----- packages/db/src/query/builder/ref-proxy.ts | 12 +++++++--- packages/db/src/query/builder/types.ts | 3 ++- packages/db/src/virtual-props.ts | 4 +++- packages/db/tests/collection-getters.test.ts | 5 ++++- .../collection-subscribe-changes.test.ts | 22 +++++++++++-------- packages/db/tests/collection-truncate.test.ts | 20 +++++++++++++---- packages/db/tests/collection.test-d.ts | 10 ++++----- packages/db/tests/collection.test.ts | 8 +++++-- packages/db/tests/query/select-spread.test.ts | 4 +++- .../query-db-collection/tests/query.test-d.ts | 12 +++++----- 11 files changed, 70 insertions(+), 38 deletions(-) diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index 56d0da941..53abe3df2 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -1,11 +1,7 @@ import { deepEquals } from '../utils' import { SortedMap } from '../SortedMap' -import { - - - enrichRowWithVirtualProps -} from '../virtual-props.js' -import type {VirtualOrigin, WithVirtualProps} from '../virtual-props.js'; +import { enrichRowWithVirtualProps } from '../virtual-props.js' +import type { VirtualOrigin, WithVirtualProps } from '../virtual-props.js' import type { Transaction } from '../transactions' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { diff --git a/packages/db/src/query/builder/ref-proxy.ts b/packages/db/src/query/builder/ref-proxy.ts index ca378a7a5..10575a12d 100644 --- a/packages/db/src/query/builder/ref-proxy.ts +++ b/packages/db/src/query/builder/ref-proxy.ts @@ -15,7 +15,9 @@ export interface RefProxy { * Virtual properties available on all row ref proxies. * These allow querying on sync status, origin, key, and collection ID. */ -export type VirtualPropsRefProxy = { +export type VirtualPropsRefProxy< + TKey extends string | number = string | number, +> = { readonly $synced: RefLeaf readonly $origin: RefLeaf<'local' | 'remote'> readonly $key: RefLeaf @@ -29,13 +31,17 @@ export type VirtualPropsRefProxy * 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 : RefLeaf - } & RefProxy & VirtualPropsRefProxy + } & RefProxy & + VirtualPropsRefProxy : RefProxy & VirtualPropsRefProxy /** diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 34ab74cd3..51e78e52e 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -544,7 +544,8 @@ export type Ref = { IsPlainObject extends true ? Ref : RefLeaf -} & RefLeaf & VirtualPropsRef +} & RefLeaf & + VirtualPropsRef /** * Ref - The user-facing ref type with clean IDE display diff --git a/packages/db/src/virtual-props.ts b/packages/db/src/virtual-props.ts index b71276dcd..7c39abc1c 100644 --- a/packages/db/src/virtual-props.ts +++ b/packages/db/src/virtual-props.ts @@ -54,7 +54,9 @@ export type VirtualOrigin = 'local' | 'remote' * }) * ``` */ -export interface VirtualRowProps { +export interface VirtualRowProps< + TKey extends string | number = string | number, +> { /** * Whether this row reflects confirmed state from the backend. * diff --git a/packages/db/tests/collection-getters.test.ts b/packages/db/tests/collection-getters.test.ts index bd12164d9..9c0e357bc 100644 --- a/packages/db/tests/collection-getters.test.ts +++ b/packages/db/tests/collection-getters.test.ts @@ -24,7 +24,10 @@ const stripValues = >( values: Array, ): Array => values.map((value) => stripVirtualProps(value)) -const stripEntries = >( +const stripEntries = < + TKey extends string | number, + T extends Record, +>( entries: Array<[TKey, T]>, ): Array<[TKey, T]> => entries.map(([key, value]) => [key, stripVirtualProps(value)]) diff --git a/packages/db/tests/collection-subscribe-changes.test.ts b/packages/db/tests/collection-subscribe-changes.test.ts index f62347360..bab80f819 100644 --- a/packages/db/tests/collection-subscribe-changes.test.ts +++ b/packages/db/tests/collection-subscribe-changes.test.ts @@ -17,7 +17,9 @@ import type { // Helper function to wait for changes to be processed const waitForChanges = () => new Promise((resolve) => setTimeout(resolve, 10)) -const stripVirtualProps = | undefined>(value: T) => { +const stripVirtualProps = | undefined>( + value: T, +) => { if (!value || typeof value !== `object`) return value const { $synced: _synced, @@ -35,7 +37,7 @@ const normalizeChange = >( ...change, value: stripVirtualProps(change.value), previousValue: change.previousValue - ? (stripVirtualProps(change.previousValue)) + ? stripVirtualProps(change.previousValue) : undefined, }) @@ -2293,9 +2295,9 @@ describe(`Virtual properties`, () => { source.insert({ id: `optimistic-1`, value: `pending` }) await waitForChanges() - expect(liveChanges.some((change) => change.value.id === `optimistic-1`)).toBe( - false, - ) + expect( + liveChanges.some((change) => change.value.id === `optimistic-1`), + ).toBe(false) liveSub.unsubscribe() await source.cleanup() @@ -2303,10 +2305,12 @@ describe(`Virtual properties`, () => { }) it(`should mark local-only collections as synced with local origin`, async () => { - const collection = createLocalOnlyCollection<{ id: string; value: string }>({ - id: `virtual-props-local-only`, - getKey: (item) => item.id, - }) + const collection = createLocalOnlyCollection<{ id: string; value: string }>( + { + id: `virtual-props-local-only`, + getKey: (item) => item.id, + }, + ) const changes: Array> = [] const subscription = collection.subscribeChanges( diff --git a/packages/db/tests/collection-truncate.test.ts b/packages/db/tests/collection-truncate.test.ts index 2f7b113ef..1b2779c27 100644 --- a/packages/db/tests/collection-truncate.test.ts +++ b/packages/db/tests/collection-truncate.test.ts @@ -851,8 +851,14 @@ describe(`Collection truncate operations`, () => { // Verify final state is correct expect(collection.state.size).toBe(2) - expect(getStateValue(collection, 1)).toEqual({ id: 1, value: `refetched-1` }) - expect(getStateValue(collection, 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() }) @@ -1156,8 +1162,14 @@ describe(`Collection truncate operations`, () => { // Verify collection state is correct expect(collection.state.size).toBe(2) - expect(getStateValue(collection, 1)).toEqual({ id: 1, value: `sync-item-1` }) - expect(getStateValue(collection, 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 93d37f31c..5b4dde647 100644 --- a/packages/db/tests/collection.test-d.ts +++ b/packages/db/tests/collection.test-d.ts @@ -59,11 +59,11 @@ describe(`Collection type resolution tests`, () => { }) type SchemaType = StandardSchemaV1.InferOutput -type ItemOf = T extends Array ? U : T -type OutputWithVirtual = WithVirtualProps< - T, - TKey -> + type ItemOf = T extends Array ? U : T + type OutputWithVirtual< + T, + TKey extends string | number = string, + > = WithVirtualProps it(`should use explicit type when provided without schema`, () => { const _collection = createCollection({ diff --git a/packages/db/tests/collection.test.ts b/packages/db/tests/collection.test.ts index 795d19a4b..92b36be0f 100644 --- a/packages/db/tests/collection.test.ts +++ b/packages/db/tests/collection.test.ts @@ -314,9 +314,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(stripVirtualProps(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(stripVirtualProps(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 }) diff --git a/packages/db/tests/query/select-spread.test.ts b/packages/db/tests/query/select-spread.test.ts index b4f0e0146..b25a95100 100644 --- a/packages/db/tests/query/select-spread.test.ts +++ b/packages/db/tests/query/select-spread.test.ts @@ -115,7 +115,9 @@ describe(`select spreads (runtime)`, () => { const results = Array.from(collection.values()) expect(results).toHaveLength(2) // Should match initial data exactly - expect(results.map((row) => stripVirtualProps(row))).toEqual(initialMessages) + expect(results.map((row) => stripVirtualProps(row))).toEqual( + initialMessages, + ) // Index access by key expect(stripVirtualProps(collection.get(1))).toEqual(initialMessages[0]) }) diff --git a/packages/query-db-collection/tests/query.test-d.ts b/packages/query-db-collection/tests/query.test-d.ts index 6472cb4e5..6ac620787 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -10,17 +10,19 @@ import { import { QueryClient } from '@tanstack/query-core' import { z } from 'zod' import { queryCollectionOptions } from '../src/query' -import type { DeleteMutationFnParams, +import type { + DeleteMutationFnParams, InsertMutationFnParams, LoadSubsetOptions, UpdateMutationFnParams, - WithVirtualProps } from '@tanstack/db' + WithVirtualProps, +} from '@tanstack/db' import type { QueryCollectionConfig, QueryCollectionUtils } from '../src/query' -type OutputWithVirtual = WithVirtualProps< +type OutputWithVirtual< T, - TKey -> + TKey extends string | number = string, +> = WithVirtualProps describe(`Query collection type resolution tests`, () => { // Define test types