Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 226 additions & 0 deletions packages/angular-db/tests/conformance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
/**
* Angular driver for the shared live-query conformance suite (#1623).
*
* `injectLiveQuery` needs an injection context, so each mount runs inside a
* child `EnvironmentInjector` created off TestBed's; `unmount` calls
* `injector.destroy()`, firing the `DestroyRef` cleanup. Result signals are read
* after settling. Controllable inputs use Angular's reactive `{ params, query }`
* form driven by a signal.
*
* `knownGaps` is populated empirically from the run below.
*/
import {
EnvironmentInjector,
createEnvironmentInjector,
runInInjectionContext,
signal,
} from '@angular/core'
import { TestBed } from '@angular/core/testing'
import {
coalesce,
count,
createCollection,
createLiveQueryCollection,
createOptimisticAction,
eq,
gt,
sum,
} from '@tanstack/db'
import {
mockSyncCollectionOptions,
mockSyncCollectionOptionsNoInitialState,
} from '../../db/tests/utils'
import { injectLiveQuery } from '../src/index'
import { runSuite } from '../../db/tests/conformance/suite'
import type {
ConformanceResult,
ControllableHandle,
DeferredSourceHandle,
LiveQueryDriver,
LiveQueryHandle,
QueryBuild,
SourceHandle,
} from '../../db/tests/conformance/contract'

let sourceSeq = 0

function writer<T extends { id: string }>(collection: any) {
return (type: `insert` | `update` | `delete`, value: T) => {
collection.utils.begin()
collection.utils.write({ type, value })
collection.utils.commit()
}
}

function makeSource<T extends { id: string }>(
initialData: ReadonlyArray<T>,
): SourceHandle<T> {
const collection = createCollection(
mockSyncCollectionOptions<T>({
id: `conformance-angular-${sourceSeq++}`,
getKey: (r) => r.id,
initialData: [...initialData],
}),
)
const write = writer<T>(collection)
return {
collection,
insert: (row) => write(`insert`, row),
update: (row) => write(`update`, row),
remove: (row) => write(`delete`, row),
}
}

function makeDeferredSource<
T extends { id: string },
>(): DeferredSourceHandle<T> {
const collection = createCollection(
mockSyncCollectionOptionsNoInitialState<T>({
id: `conformance-angular-${sourceSeq++}`,
getKey: (r) => r.id,
}),
)
collection.startSyncImmediate()
const write = writer<T>(collection)
return {
collection,
insert: (row) => write(`insert`, row),
update: (row) => write(`update`, row),
remove: (row) => write(`delete`, row),
emit: (rows) => {
collection.utils.begin()
rows.forEach((value) => collection.utils.write({ type: `insert`, value }))
collection.utils.commit()
},
markReady: () => collection.utils.markReady(),
}
}

function makePrecreated(build: QueryBuild, opts?: { startSync?: boolean }) {
const collection = createLiveQueryCollection({
query: build as any,
startSync: opts?.startSync ?? true,
})
return { collection }
}

function makeErrorSource() {
const collection = createCollection<{ id: string }>({
id: `conformance-angular-err-${sourceSeq++}`,
getKey: (r) => r.id,
startSync: false,
sync: {
sync: () => {
throw new Error(`conformance: sync failure`)
},
},
})
try {
collection.startSyncImmediate()
} catch {
// expected: engine catches the sync error and sets status to `error`
}
return { collection }
}

async function settle() {
await new Promise((resolve) => setTimeout(resolve, 0))
await new Promise((resolve) => setTimeout(resolve, 50))
}

function makeHandle(result: any, destroy: () => void): LiveQueryHandle {
return {
current(): ConformanceResult {
return {
data: result.data(),
status: result.status(),
isReady: Boolean(result.isReady()),
isError: Boolean(result.isError()),
isEnabled: result.status() !== `disabled`,
}
},
flush: settle,
async apply(fn: () => void) {
fn()
await settle()
},
unmount() {
destroy()
},
}
}

function inCtx(fn: () => any): { result: any; destroy: () => void } {
const parent = TestBed.inject(EnvironmentInjector)
const injector = createEnvironmentInjector([], parent)
let result: any
runInInjectionContext(injector, () => {
result = fn()
})
return { result, destroy: () => injector.destroy() }
}

function mount(build: QueryBuild) {
const { result, destroy } = inCtx(() => injectLiveQuery(build as any))
return makeHandle(result, destroy)
}

function mountCollection(collection: any) {
const { result, destroy } = inCtx(() => injectLiveQuery(collection))
return makeHandle(result, destroy)
}

function mountConfig(build: QueryBuild) {
const { result, destroy } = inCtx(() => injectLiveQuery({ query: build }))
return makeHandle(result, destroy)
}

function mountDisabled() {
const { result, destroy } = inCtx(() => injectLiveQuery(() => null))
return makeHandle(result, destroy)
}

function mountControllable<P>(
build: (q: any, param: P) => any,
initial: P,
): ControllableHandle<P> {
const param = signal<P>(initial)
const { result, destroy } = inCtx(() =>
injectLiveQuery({
params: () => ({ value: param() }),
query: ({ params, q }: any) => build(q, params.value),
}),
)
const handle = makeHandle(result, destroy)
return {
...handle,
async setParam(next: P) {
param.set(next)
await settle()
},
}
}

const angularDriver: LiveQueryDriver = {
name: `angular`,
ops: { eq, gt, count, sum, coalesce, createOptimisticAction },
makeSource,
makeDeferredSource,
makePrecreated,
makeErrorSource,
mount,
mountControllable,
mountCollection,
mountConfig,
mountDisabled,
// Divergence the suite surfaced: angular-db's plain `{ query }` config-object
// path calls createLiveQueryCollection(opts) as-is, without injecting
// startSync:true the way the query-fn path does — so a bare `{ query }` never
// syncs and returns empty. React/Vue/Svelte/Solid all auto-start a config
// object; Angular requires an explicit `startSync: true` (its own config test
// passes it). Recorded until angular-db aligns.
knownGaps: [`config-object-input`],
features: { serverSnapshot: false, suspense: false },
}

runSuite(angularDriver)
149 changes: 149 additions & 0 deletions packages/db/tests/conformance/contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Cross-adapter live-query conformance harness — shared contract.
*
* ONE behavioral spec for `useLiveQuery`, run against every framework adapter.
* Each adapter provides a thin `LiveQueryDriver` and the shared suite in
* `suite.ts` does the rest.
*
* Realm safety: the driver — not the scenarios — creates source collections and
* supplies query operators, both imported from the *adapter's* copy of
* `@tanstack/db`. This keeps collection instances and expression nodes in the
* same module realm as the adapter's hook, avoiding the dual-package
* `instanceof CollectionImpl` mismatch. Scenarios never import `@tanstack/db`.
*
* Expected-fail policy: `knownGaps` lists scenario KEYS this adapter does not
* yet satisfy. Populate it EMPIRICALLY — port a behavior, run it, and only add
* the key if it actually fails. The behavior matrix tells you where to look;
* the test run tells you what's broken. When a gap closes, `it.fails` errors
* ("expected to fail but passed") prompting you to delete the key.
*/
import type { Collection } from '@tanstack/db'

/** Default row shape used by the base scenarios (a "person"). */
export interface Row {
id: string
name: string
age: number
team: string
}

/**
* A realm-correct source collection plus sync-driven mutators. Generic over the
* row type so relational scenarios (join, includes) can build differently-shaped
* related collections; keyed by `id` in every case.
*/
export interface SourceHandle<T extends { id: string } = Row> {
collection: Collection<any, any, any>
insert: (row: T) => void
update: (row: T) => void
remove: (row: T) => void
}

/**
* A source whose readiness the scenario controls, for loading/eager/ready
* transitions. Starts in `loading` (synced, not ready) so scenarios can `emit`
* rows while still loading and then `markReady`.
*/
export interface DeferredSourceHandle<
T extends { id: string } = Row,
> extends SourceHandle<T> {
/** Write rows without marking ready — exercises the eager (visible-while-loading) path. */
emit: (rows: ReadonlyArray<T>) => void
/** Transition the source from `loading` to `ready`. */
markReady: () => void
}

/**
* The subset of `@tanstack/db` query operators scenarios need, supplied by the
* driver from the adapter's realm. Grows as engine scenarios are ported.
*/
export interface DbOps {
eq: (a: any, b: any) => any
gt: (a: any, b: any) => any
count: (a: any) => any
sum: (a: any) => any
coalesce: (...args: Array<any>) => any
/** Build an optimistic action: onMutate applies optimistic state, mutationFn confirms. */
createOptimisticAction: (config: {
onMutate: (variables: any) => void
mutationFn: (variables: any) => Promise<any>
}) => (variables?: any) => { isPersisted: { promise: Promise<any> } }
}

/** Normalized, adapter-agnostic view of a live query's current result. */
export interface ConformanceResult {
/** Array for list queries; a single row (or undefined) for `findOne`. */
data: any
status: string
isReady: boolean
isError: boolean
isEnabled: boolean
}

/** A query-builder callback, e.g. `(q) => q.from({ items: source.collection })`. */
export type QueryBuild = (q: any) => any

/** A mounted live query under test. */
export interface LiveQueryHandle {
current: () => ConformanceResult
/** Let the framework scheduler + core sync settle, then resolve. */
flush: () => Promise<void>
/**
* Run a state-mutating callback inside the framework's update scope, then
* settle (React `act`, Vue `nextTick`, Svelte `flushSync`, Solid `batch`).
* Needed when a mutation notifies synchronously, e.g. optimistic actions.
*/
apply: (fn: () => void) => Promise<void>
unmount: () => void
}

/**
* A mounted query whose input parameter can change after mount, for
* recompilation and disabled/enabled transitions. `setParam` re-renders with the
* new value and settles.
*/
export interface ControllableHandle<P> extends LiveQueryHandle {
setParam: (param: P) => Promise<void>
}

/** What each adapter package implements and hands to `runSuite`. */
export interface LiveQueryDriver {
name: string
/** Operators from the adapter's `@tanstack/db` realm. */
ops: DbOps
/** Create a realm-correct source collection + mutators, keyed by `id`. */
makeSource: <T extends { id: string }>(
initialData: ReadonlyArray<T>,
) => SourceHandle<T>
/** Create a source that starts `loading` and readies on demand (keyed by `id`). */
makeDeferredSource: <T extends { id: string }>() => DeferredSourceHandle<T>
/**
* Create a pre-built live-query collection to pass straight to the hook.
* `startSync: false` yields a not-yet-syncing collection (isReady false).
*/
makePrecreated: (
build: QueryBuild,
opts?: { startSync?: boolean },
) => { collection: Collection<any, any, any> }
/** Create a source whose sync fails, driving it into `error` status. */
makeErrorSource: () => { collection: Collection<any, any, any> }
/** Mount a live query from a query-builder callback. */
mount: (build: QueryBuild) => LiveQueryHandle
/**
* Mount a live query whose input depends on a parameter that can change after
* mount. `build` returns a query, or `null`/`undefined` to represent disabled.
*/
mountControllable: <P>(
build: (q: any, param: P) => any,
initial: P,
) => ControllableHandle<P>
/** Mount a pre-created collection passed directly to the hook. */
mountCollection: (collection: Collection<any, any, any>) => LiveQueryHandle
/** Mount via the config-object input form (`{ query: build }`). */
mountConfig: (build: QueryBuild) => LiveQueryHandle
/** Mount an explicitly-disabled query (adapter's own null/undefined form). */
mountDisabled: () => LiveQueryHandle
/** Scenario keys this adapter is empirically known NOT to satisfy yet. */
knownGaps?: ReadonlyArray<string>
features?: { serverSnapshot?: boolean; suspense?: boolean }
}
Loading
Loading