diff --git a/packages/angular-db/tests/conformance.test.ts b/packages/angular-db/tests/conformance.test.ts new file mode 100644 index 0000000000..b60b8fa3d1 --- /dev/null +++ b/packages/angular-db/tests/conformance.test.ts @@ -0,0 +1,227 @@ +/** + * Angular driver for the shared live-query conformance suite. + * + * `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(collection: any) { + return (type: `insert` | `update` | `delete`, value: T) => { + collection.utils.begin() + collection.utils.write({ type, value }) + collection.utils.commit() + } +} + +function makeSource( + initialData: ReadonlyArray, +): SourceHandle { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `conformance-angular-${sourceSeq++}`, + getKey: (r) => r.id, + initialData: [...initialData], + }), + ) + const write = writer(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 { + const collection = createCollection( + mockSyncCollectionOptionsNoInitialState({ + id: `conformance-angular-${sourceSeq++}`, + getKey: (r) => r.id, + }), + ) + collection.startSyncImmediate() + const write = writer(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()), + // angular-db exposes no `isEnabled`; derive it from status (status-derived). + 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

( + build: (q: any, param: P) => any, + initial: P, +): ControllableHandle

{ + const param = signal

(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) diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts new file mode 100644 index 0000000000..7726f558bd --- /dev/null +++ b/packages/db/tests/conformance/contract.ts @@ -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 { + collection: Collection + 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 { + /** Write rows without marking ready — exercises the eager (visible-while-loading) path. */ + emit: (rows: ReadonlyArray) => 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 + /** Build an optimistic action: onMutate applies optimistic state, mutationFn confirms. */ + createOptimisticAction: (config: { + onMutate: (variables: any) => void + mutationFn: (variables: any) => Promise + }) => (variables?: any) => { isPersisted: { promise: Promise } } +} + +/** 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 + /** + * 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 + 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

extends LiveQueryHandle { + setParam: (param: P) => Promise +} + +/** 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: ( + initialData: ReadonlyArray, + ) => SourceHandle + /** Create a source that starts `loading` and readies on demand (keyed by `id`). */ + makeDeferredSource: () => DeferredSourceHandle + /** + * 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 } + /** Create a source whose sync fails, driving it into `error` status. */ + makeErrorSource: () => { collection: Collection } + /** 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:

( + build: (q: any, param: P) => any, + initial: P, + ) => ControllableHandle

+ /** Mount a pre-created collection passed directly to the hook. */ + mountCollection: (collection: Collection) => 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 + features?: { serverSnapshot?: boolean; suspense?: boolean } +} diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts new file mode 100644 index 0000000000..eb52b18260 --- /dev/null +++ b/packages/db/tests/conformance/suite.ts @@ -0,0 +1,694 @@ +/** + * Shared live-query conformance suite. + * + * Sourced bottom-up from the union of the five adapters' existing test suites + * (the "spine" + framework-agnostic "gap-closers"), plus a small tail of + * behaviors no adapter tests yet but all should (encoded as expected-fail). + * + * Each scenario has a stable KEY. An adapter marks a key in `driver.knownGaps` + * (populated empirically by running, not from the coverage matrix) to assert it + * as expected-fail. `UNIVERSAL_EXPECTED_FAIL` keys fail on every adapter until + * the underlying core gap is fixed. + * + * Coverage: query/where/select, live insert/update/delete, orderBy, join, + * groupBy/aggregate, nested aggregates, `.includes` subqueries, findOne + * cardinality, disabled + transitions, deferred readiness / eager / ready-with- + * no-data, param recompilation, optimistic mutation, pre-created & config-object + * inputs, error status, and the order-only-move tail (expected-fail). + */ +import { describe, expect, it } from 'vitest' +import type { LiveQueryDriver, LiveQueryHandle, Row } from './contract' + +const SEED: Array = [ + { id: `1`, name: `John Doe`, age: 30, team: `a` }, + { id: `2`, name: `Jane Doe`, age: 25, team: `b` }, + { id: `3`, name: `John Smith`, age: 35, team: `a` }, +] + +interface Issue { + id: string + title: string + userId: string +} + +// Issues reference SEED people: John(1) has 2, Jane(2) has 1, John Smith(3) has 0. +const ISSUES: Array = [ + { id: `i1`, title: `Issue 1`, userId: `1` }, + { id: `i2`, title: `Issue 2`, userId: `2` }, + { id: `i3`, title: `Issue 3`, userId: `1` }, +] + +/** Keys that are expected to fail on ALL adapters (core gaps, not adapter drift). */ +const UNIVERSAL_EXPECTED_FAIL = new Set([`order-only-move`]) + +export function runSuite(rawDriver: LiveQueryDriver) { + const { ops } = rawDriver + const gaps = new Set(rawDriver.knownGaps ?? []) + + // Every scenario key registered below, used to validate `knownGaps` / + // `UNIVERSAL_EXPECTED_FAIL` don't reference a stale or misspelled key. + const registeredKeys = new Set() + + // Track every handle mounted during the current scenario so it is always torn + // down, even when an (expected-fail) scenario throws before its own + // `h.unmount()`. Wrapping the driver's `mount*` methods records handles + // automatically, so scenario bodies need no `try/finally` of their own. + let mounted: Array | null = null + const track = (handle: H): H => { + mounted?.push(handle) + return handle + } + const driver: LiveQueryDriver = { + ...rawDriver, + mount: (build) => track(rawDriver.mount(build)), + mountControllable: (build, initial) => + track(rawDriver.mountControllable(build, initial)), + mountCollection: (collection) => + track(rawDriver.mountCollection(collection)), + mountConfig: (build) => track(rawDriver.mountConfig(build)), + mountDisabled: () => track(rawDriver.mountDisabled()), + } + + /** Register a scenario as `it` or `it.fails` based on known gaps. */ + const scenario = ( + key: string, + name: string, + fn: () => Promise | void, + ) => { + registeredKeys.add(key) + const expectFail = gaps.has(key) || UNIVERSAL_EXPECTED_FAIL.has(key) + const label = `[${key}] ${name}${expectFail ? ` (expected-fail)` : ``}` + const run = async () => { + const handles: Array = [] + mounted = handles + try { + await fn() + } finally { + mounted = null + for (const handle of handles) { + try { + handle.unmount() + } catch { + // teardown is best-effort / idempotent + } + } + } + } + if (expectFail) it.fails(label, run) + else it(label, run) + } + + describe(`live-query conformance :: ${driver.name}`, () => { + // ---- spine: query + liveness ---------------------------------------- + + scenario( + `basic-select`, + `from + where + select returns matching rows`, + async () => { + const source = driver.makeSource(SEED) + const h = driver.mount((q) => + q + .from({ items: source.collection }) + .where(({ items }: any) => ops.gt(items.age, 30)) + .select(({ items }: any) => ({ id: items.id, name: items.name })), + ) + await h.flush() + + expect(h.current().data).toHaveLength(1) + expect(h.current().data[0]).toMatchObject({ + id: `3`, + name: `John Smith`, + }) + h.unmount() + }, + ) + + scenario(`live-insert`, `a sync insert appears in the result`, async () => { + const source = driver.makeSource(SEED) + const h = driver.mount((q) => + q + .from({ items: source.collection }) + .select(({ items }: any) => ({ id: items.id })), + ) + await h.flush() + expect(h.current().data).toHaveLength(SEED.length) + + source.insert({ id: `4`, name: `Dave`, age: 40, team: `b` }) + await h.flush() + + expect(h.current().data).toHaveLength(SEED.length + 1) + h.unmount() + }) + + scenario( + `live-delete`, + `a sync delete removes from the result`, + async () => { + const source = driver.makeSource(SEED) + const h = driver.mount((q) => + q + .from({ items: source.collection }) + .select(({ items }: any) => ({ id: items.id })), + ) + await h.flush() + + source.remove(SEED[0]!) + await h.flush() + + expect(h.current().data).toHaveLength(SEED.length - 1) + h.unmount() + }, + ) + + scenario(`orderby`, `orderBy yields rows in sorted order`, async () => { + const source = driver.makeSource(SEED) + const h = driver.mount((q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.age) + .select(({ items }: any) => ({ id: items.id })), + ) + await h.flush() + + expect(h.current().data.map((r: any) => r.id)).toEqual([`2`, `1`, `3`]) + h.unmount() + }) + + // ---- gap-closer: cardinality (matrix: Vue tests this 0 times) -------- + + scenario( + `findone-cardinality`, + `findOne returns a single row, not an array`, + async () => { + const source = driver.makeSource(SEED) + const h = driver.mount((q) => + q + .from({ items: source.collection }) + .where(({ items }: any) => ops.eq(items.id, `3`)) + .findOne(), + ) + await h.flush() + + expect(Array.isArray(h.current().data)).toBe(false) + expect(h.current().data).toMatchObject({ id: `3`, name: `John Smith` }) + h.unmount() + }, + ) + + // ---- gap-closer: disabled (matrix: Svelte tests this 0 times) -------- + + scenario( + `disabled-explicit`, + `a disabled query reports isEnabled=false with no data`, + async () => { + const h = driver.mountDisabled() + await h.flush() + + expect(h.current().isEnabled).toBe(false) + expect(h.current().data ?? []).toHaveLength(0) + h.unmount() + }, + ) + + // ---- spine: lifecycle invariant -------------------------------------- + + scenario( + `no-updates-after-unmount`, + `no result mutation after unmount`, + async () => { + const source = driver.makeSource(SEED) + const h = driver.mount((q) => + q + .from({ items: source.collection }) + .select(({ items }: any) => ({ id: items.id })), + ) + await h.flush() + const before = h.current().data.length + + h.unmount() + source.insert({ id: `99`, name: `Zed`, age: 1, team: `b` }) + await h.flush() + + expect(h.current().data.length).toBe(before) + }, + ) + + // ---- spine: relational + aggregate queries --------------------------- + + scenario(`join`, `join across two collections`, async () => { + const people = driver.makeSource(SEED) + const issues = driver.makeSource(ISSUES) + const h = driver.mount((q) => + q + .from({ issues: issues.collection }) + .join({ persons: people.collection }, ({ issues: i, persons }: any) => + ops.eq(i.userId, persons.id), + ) + .select(({ issues: i, persons }: any) => ({ + id: i.id, + title: i.title, + name: persons.name, + })), + ) + await h.flush() + + expect(h.current().data).toHaveLength(ISSUES.length) + expect(h.current().data.find((r: any) => r.id === `i1`)).toMatchObject({ + title: `Issue 1`, + name: `John Doe`, + }) + h.unmount() + }) + + scenario( + `groupby-aggregate`, + `groupBy + count aggregates per group`, + async () => { + const people = driver.makeSource(SEED) + const h = driver.mount((q) => + q + .from({ items: people.collection }) + .groupBy(({ items }: any) => items.team) + .select(({ items }: any) => ({ + team: items.team, + count: ops.count(items.id), + })), + ) + await h.flush() + + const byTeam = new Map( + h.current().data.map((r: any) => [r.team, r.count]), + ) + expect(byTeam.get(`a`)).toBe(2) + expect(byTeam.get(`b`)).toBe(1) + h.unmount() + }, + ) + + // ---- gap-closers: engine features tested only by React today --------- + + scenario( + `nested-aggregates`, + `coalesce(count(...), 0) in a joined subquery`, + async () => { + const people = driver.makeSource(SEED) + const issues = driver.makeSource(ISSUES) + const h = driver.mount((q) => { + const issueCounts = q + .from({ issues: issues.collection }) + .groupBy(({ issues: i }: any) => i.userId) + .select(({ issues: i }: any) => ({ + userId: i.userId, + issueCount: ops.coalesce(ops.count(i.id), 0), + })) + return q + .from({ persons: people.collection }) + .leftJoin({ ic: issueCounts }, ({ persons, ic }: any) => + ops.eq(persons.id, ic.userId), + ) + .select(({ persons, ic }: any) => ({ + name: persons.name, + issueCount: ic.issueCount, + })) + }) + await h.flush() + + const byName = new Map( + h.current().data.map((r: any) => [r.name, r.issueCount]), + ) + expect(byName.get(`John Doe`)).toBe(2) + expect(byName.get(`Jane Doe`)).toBe(1) + h.unmount() + }, + ) + + scenario( + `includes-subquery`, + `select with a nested subquery produces child collections`, + async () => { + const people = driver.makeSource(SEED) + const issues = driver.makeSource(ISSUES) + const h = driver.mount((q) => + q.from({ persons: people.collection }).select(({ persons }: any) => ({ + id: persons.id, + name: persons.name, + issues: q + .from({ issues: issues.collection }) + .where(({ issues: i }: any) => ops.eq(i.userId, persons.id)) + .select(({ issues: i }: any) => ({ id: i.id, title: i.title })), + })), + ) + await h.flush() + + expect(h.current().data).toHaveLength(SEED.length) + const john = h.current().data.find((r: any) => r.id === `1`) + // `john.issues` is a child collection; read its contents through the + // collection API. John (id 1) has issues i1 and i3. + expect(john.issues).toBeDefined() + const johnIssueIds = Array.from(john.issues.values()) + .map((i: any) => i.id) + .sort() + expect(johnIssueIds).toEqual([`i1`, `i3`]) + h.unmount() + }, + ) + + // ---- free ports (no new capability needed) --------------------------- + + scenario(`live-update`, `a sync update is reflected in place`, async () => { + const source = driver.makeSource(SEED) + const h = driver.mount((q) => + q + .from({ items: source.collection }) + .select(({ items }: any) => ({ id: items.id, name: items.name })), + ) + await h.flush() + + source.update({ id: `1`, name: `Johnny Doe`, age: 30, team: `a` }) + await h.flush() + + expect(h.current().data.find((r: any) => r.id === `1`).name).toBe( + `Johnny Doe`, + ) + h.unmount() + }) + + scenario( + `findone-reactive`, + `findOne updates in place and becomes undefined on delete`, + async () => { + const source = driver.makeSource(SEED) + const h = driver.mount((q) => + q + .from({ items: source.collection }) + .where(({ items }: any) => ops.eq(items.id, `3`)) + .findOne(), + ) + await h.flush() + expect(h.current().data).toMatchObject({ name: `John Smith` }) + + source.update({ id: `3`, name: `Johnny Smith`, age: 35, team: `a` }) + await h.flush() + expect(h.current().data).toMatchObject({ name: `Johnny Smith` }) + + source.remove({ id: `3`, name: `Johnny Smith`, age: 35, team: `a` }) + await h.flush() + expect(h.current().data ?? undefined).toBeUndefined() + h.unmount() + }, + ) + + // ---- Tier 2: deferred readiness -------------------------------------- + + scenario( + `isready-transition`, + `isReady flips from false to true when the source readies`, + async () => { + const source = driver.makeDeferredSource() + const h = driver.mount((q) => + q + .from({ items: source.collection }) + .select(({ items }: any) => ({ id: items.id })), + ) + await h.flush() + expect(h.current().isReady).toBe(false) + + source.markReady() + await h.flush() + expect(h.current().isReady).toBe(true) + h.unmount() + }, + ) + + scenario( + `eager-visible-while-loading`, + `rows emitted before ready are visible while still loading`, + async () => { + const source = driver.makeDeferredSource() + const h = driver.mount((q) => + q + .from({ items: source.collection }) + .select(({ items }: any) => ({ id: items.id })), + ) + await h.flush() + + source.emit(SEED) + await h.flush() + + expect(h.current().isReady).toBe(false) + expect(h.current().data).toHaveLength(SEED.length) + + source.markReady() + await h.flush() + expect(h.current().isReady).toBe(true) + h.unmount() + }, + ) + + scenario( + `isready-no-data`, + `isReady becomes true even when the source readies with no rows`, + async () => { + const source = driver.makeDeferredSource() + const h = driver.mount((q) => + q + .from({ items: source.collection }) + .select(({ items }: any) => ({ id: items.id })), + ) + await h.flush() + + source.markReady() + await h.flush() + + expect(h.current().isReady).toBe(true) + expect(h.current().data ?? []).toHaveLength(0) + h.unmount() + }, + ) + + // ---- Tier 2: controllable input -------------------------------------- + + scenario( + `param-recompile`, + `changing a query parameter recompiles the result`, + async () => { + const source = driver.makeSource(SEED) + const h = driver.mountControllable( + (q, minAge) => + q + .from({ items: source.collection }) + .where(({ items }: any) => ops.gt(items.age, minAge)) + .select(({ items }: any) => ({ id: items.id })), + 30, + ) + await h.flush() + expect(h.current().data).toHaveLength(1) // age > 30 → John Smith + + await h.setParam(20) + expect(h.current().data).toHaveLength(3) // all + + await h.setParam(50) + expect(h.current().data).toHaveLength(0) // none + h.unmount() + }, + ) + + scenario( + `disabled-transition`, + `disabled -> enabled -> disabled toggles correctly`, + async () => { + const source = driver.makeSource(SEED) + const h = driver.mountControllable( + (q, enabled) => + enabled + ? q + .from({ items: source.collection }) + .select(({ items }: any) => ({ id: items.id })) + : null, + false, + ) + await h.flush() + expect(h.current().isEnabled).toBe(false) + + await h.setParam(true) + expect(h.current().isEnabled).toBe(true) + expect(h.current().data).toHaveLength(SEED.length) + + await h.setParam(false) + expect(h.current().isEnabled).toBe(false) + h.unmount() + }, + ) + + // ---- Tier 2: optimistic mutation ------------------------------------- + + scenario( + `optimistic-insert`, + `optimistic insert is visible immediately, then reconciles to the server key`, + async () => { + const source = driver.makeSource(SEED) + const h = driver.mount((q) => + q + .from({ items: source.collection }) + .select(({ items }: any) => ({ id: items.id })), + ) + await h.flush() + + const temp: Row = { id: `temp`, name: `New`, age: 20, team: `c` } + const perm: Row = { id: `p9`, name: `New`, age: 20, team: `c` } + // The "server" confirms only when we release it, so the optimistic + // window is deterministic rather than racing the settle. + let confirmServer!: () => void + const serverConfirmed = new Promise((resolve) => { + confirmServer = resolve + }) + const add = ops.createOptimisticAction({ + onMutate: () => source.collection.insert(temp), + mutationFn: async () => { + await serverConfirmed + source.remove(temp) + source.insert(perm) + }, + }) + + let tx!: { isPersisted: { promise: Promise } } + await h.apply(() => { + tx = add() + }) + // Optimistic row is visible before the server confirms. + expect(h.current().data.find((r: any) => r.id === `temp`)).toBeDefined() + + confirmServer() + await tx.isPersisted.promise + await h.flush() + // Reconciled: temp replaced by the permanent key. + expect( + h.current().data.find((r: any) => r.id === `temp`), + ).toBeUndefined() + expect(h.current().data.find((r: any) => r.id === `p9`)).toBeDefined() + h.unmount() + }, + ) + + // ---- Tier 3: input variants + error status --------------------------- + + scenario( + `precreated-collection-ready`, + `accepts a pre-created (syncing) live-query collection`, + async () => { + const source = driver.makeSource(SEED) + const pre = driver.makePrecreated( + (q) => + q + .from({ items: source.collection }) + .select(({ items }: any) => ({ id: items.id })), + { startSync: true }, + ) + const h = driver.mountCollection(pre.collection) + await h.flush() + + expect(h.current().isReady).toBe(true) + expect(h.current().data).toHaveLength(SEED.length) + h.unmount() + }, + ) + + scenario( + `precreated-not-syncing-isready-false`, + `a pre-created collection over a not-ready source reports isReady=false`, + async () => { + // Both the live query (startSync: false) and its source are not ready. + // Even if the adapter eagerly starts the collection on mount, it cannot + // become ready because the source never readies — so isReady stays false. + const source = driver.makeDeferredSource() + const pre = driver.makePrecreated( + (q) => + q + .from({ items: source.collection }) + .select(({ items }: any) => ({ id: items.id })), + { startSync: false }, + ) + const h = driver.mountCollection(pre.collection) + await h.flush() + expect(h.current().isReady).toBe(false) + h.unmount() + }, + ) + + scenario( + `config-object-input`, + `accepts the { query } config-object input form`, + async () => { + const source = driver.makeSource(SEED) + const h = driver.mountConfig((q) => + q + .from({ items: source.collection }) + .where(({ items }: any) => ops.eq(items.id, `3`)) + .select(({ items }: any) => ({ id: items.id, name: items.name })), + ) + await h.flush() + + expect(h.current().data).toHaveLength(1) + expect(h.current().data[0]).toMatchObject({ id: `3` }) + h.unmount() + }, + ) + + scenario( + `error-status`, + `a failing source surfaces status=error / isError`, + async () => { + const source = driver.makeErrorSource() + const h = driver.mountCollection(source.collection) + await h.flush() + + expect(h.current().status).toBe(`error`) + expect(h.current().isError).toBe(true) + h.unmount() + }, + ) + + // ---- tail: universal expected-fail --------------------------- + + scenario( + `order-only-move`, + `an order-only move republishes the ordered result`, + async () => { + const source = driver.makeSource(SEED) + // Project only id+name; sort by age. Changing age reorders the result + // WITHOUT changing any projected row value. + const h = driver.mount((q) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.age) + .select(({ items }: any) => ({ id: items.id, name: items.name })), + ) + await h.flush() + const first = h.current().data.map((r: any) => r.id) // ['2','1','3'] + + source.update({ id: `2`, name: `Jane Doe`, age: 99, team: `b` }) + await h.flush() + + expect(h.current().data.map((r: any) => r.id)).not.toEqual(first) + h.unmount() + }, + ) + + // ---- meta: guard against stale/misspelled expected-fail keys --------- + + it(`every knownGap / universal expected-fail references a real scenario`, () => { + for (const key of rawDriver.knownGaps ?? []) { + expect( + registeredKeys.has(key), + `${driver.name} knownGaps has "${key}", which is not a scenario key`, + ).toBe(true) + } + for (const key of UNIVERSAL_EXPECTED_FAIL) { + expect( + registeredKeys.has(key), + `UNIVERSAL_EXPECTED_FAIL has "${key}", which is not a scenario key`, + ).toBe(true) + } + }) + }) +} diff --git a/packages/react-db/tests/conformance.test.tsx b/packages/react-db/tests/conformance.test.tsx new file mode 100644 index 0000000000..303bbd89e6 --- /dev/null +++ b/packages/react-db/tests/conformance.test.tsx @@ -0,0 +1,213 @@ +/** + * React reference driver for the shared live-query conformance suite. + * + * Everything realm-sensitive — collection creation and query operators — is + * imported here (React package's `@tanstack/db`) and handed to the shared + * scenarios, so instances match what this package's `useLiveQuery` expects. + * + * `knownGaps` is populated empirically from the run below, NOT from the coverage + * matrix: only keys that actually fail belong here. Today that's just the + * universal order-only-move case (handled by the shared suite), so the list is empty. + */ +import { act, renderHook } from '@testing-library/react' +import { + coalesce, + count, + createCollection, + createLiveQueryCollection, + createOptimisticAction, + eq, + gt, + sum, +} from '@tanstack/db' +import { + mockSyncCollectionOptions, + mockSyncCollectionOptionsNoInitialState, +} from '../../db/tests/utils' +import { useLiveQuery } from '../src/useLiveQuery' +import { runSuite } from '../../db/tests/conformance/suite' +import type { RenderHookResult } from '@testing-library/react' +import type { + ConformanceResult, + ControllableHandle, + DeferredSourceHandle, + LiveQueryDriver, + QueryBuild, + SourceHandle, +} from '../../db/tests/conformance/contract' + +let sourceSeq = 0 + +function writer(collection: any) { + return (type: `insert` | `update` | `delete`, value: T) => { + collection.utils.begin() + collection.utils.write({ type, value }) + collection.utils.commit() + } +} + +function makeSource( + initialData: ReadonlyArray, +): SourceHandle { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `conformance-react-${sourceSeq++}`, + getKey: (r) => r.id, + initialData: [...initialData], + }), + ) + const write = writer(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 { + const collection = createCollection( + mockSyncCollectionOptionsNoInitialState({ + id: `conformance-react-${sourceSeq++}`, + getKey: (r) => r.id, + }), + ) + // Start sync so the sync fn binds utils and the collection sits in `loading` + // (NoInitialState never calls markReady on its own). + collection.startSyncImmediate() + const write = writer(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-react-err-${sourceSeq++}`, + getKey: (r) => r.id, + startSync: false, + sync: { + sync: () => { + throw new Error(`conformance: sync failure`) + }, + }, + }) + // Starting sync throws → engine catches and sets status to `error`. + try { + collection.startSyncImmediate() + } catch { + // expected: the rethrown sync error; status is already `error` + } + return { collection } +} + +function mount(build: QueryBuild) { + const hook = renderHook(() => useLiveQuery(build as any)) + return makeHandle(hook) +} + +function mountCollection(collection: any) { + const hook = renderHook(() => useLiveQuery(collection)) + return makeHandle(hook) +} + +function mountConfig(build: QueryBuild) { + const hook = renderHook(() => useLiveQuery({ query: build as any })) + return makeHandle(hook) +} + +function mountDisabled() { + // React's disabled convention: the query callback returns null. + const hook = renderHook(() => useLiveQuery(() => null as any)) + return makeHandle(hook) +} + +function mountControllable

( + build: (q: any, param: P) => any, + initial: P, +): ControllableHandle

{ + const hook = renderHook( + ({ param }: { param: P }) => + // Param goes in the dependency list so the hook recompiles when it changes. + useLiveQuery((q: any) => build(q, param), [param]), + { initialProps: { param: initial } }, + ) + const handle = makeHandle(hook) + return { + ...handle, + async setParam(param: P) { + await act(async () => { + hook.rerender({ param }) + }) + await handle.flush() + }, + } +} + +function makeHandle(hook: RenderHookResult) { + return { + current(): ConformanceResult { + const r: any = hook.result.current + return { + data: r?.data, + status: r?.status ?? `idle`, + isReady: Boolean(r?.isReady), + isError: Boolean(r?.isError), + // Read react-db's real `isEnabled` field so the suite catches a broken + // one (deriving from status would mask it). + isEnabled: Boolean(r?.isEnabled), + } + }, + async flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + }, + async apply(fn: () => void) { + await act(async () => { + fn() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + }, + unmount() { + hook.unmount() + }, + } +} + +const reactDriver: LiveQueryDriver = { + name: `react`, + ops: { eq, gt, count, sum, coalesce, createOptimisticAction }, + makeSource, + makeDeferredSource, + makePrecreated, + makeErrorSource, + mount, + mountControllable, + mountCollection, + mountConfig, + mountDisabled, + knownGaps: [], + features: { serverSnapshot: true, suspense: true }, +} + +runSuite(reactDriver) diff --git a/packages/solid-db/tests/conformance.test.tsx b/packages/solid-db/tests/conformance.test.tsx new file mode 100644 index 0000000000..023addafd3 --- /dev/null +++ b/packages/solid-db/tests/conformance.test.tsx @@ -0,0 +1,226 @@ +/** + * Solid driver for the shared live-query conformance suite. + * + * Each mount runs inside a `createRoot` so `unmount` disposes via the captured + * dispose fn; the root stays alive between mount and reads so Solid's reactive + * getters stay current. Solid auto-tracks signals, so controllable inputs use a + * signal read inside the query fn (no deps array). Collection/config inputs are + * passed as accessors, per Solid's arity-based input detection. + * + * `knownGaps` is populated empirically from the run below. + */ +import { + coalesce, + count, + createCollection, + createLiveQueryCollection, + createOptimisticAction, + eq, + gt, + sum, +} from '@tanstack/db' +import { createRoot, createSignal } from 'solid-js' +import { + mockSyncCollectionOptions, + mockSyncCollectionOptionsNoInitialState, +} from '../../db/tests/utils' +import { useLiveQuery } from '../src/useLiveQuery' +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(collection: any) { + return (type: `insert` | `update` | `delete`, value: T) => { + collection.utils.begin() + collection.utils.write({ type, value }) + collection.utils.commit() + } +} + +function makeSource( + initialData: ReadonlyArray, +): SourceHandle { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `conformance-solid-${sourceSeq++}`, + getKey: (r) => r.id, + initialData: [...initialData], + }), + ) + const write = writer(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 { + const collection = createCollection( + mockSyncCollectionOptionsNoInitialState({ + id: `conformance-solid-${sourceSeq++}`, + getKey: (r) => r.id, + }), + ) + collection.startSyncImmediate() + const write = writer(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-solid-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, 10)) +} + +function makeHandle( + getResult: () => any, + dispose: () => void, +): LiveQueryHandle { + return { + current(): ConformanceResult { + const result = getResult() + return { + data: result?.data, + status: result?.status ?? `idle`, + isReady: Boolean(result?.isReady), + isError: Boolean(result?.isError), + // solid-db exposes no `isEnabled`; derive it from status (status-derived). + isEnabled: result?.status !== `disabled`, + } + }, + flush: settle, + async apply(fn: () => void) { + fn() + await settle() + }, + unmount() { + dispose() + }, + } +} + +function inRoot(fn: () => any): { getResult: () => any; dispose: () => void } { + let result: any + let dispose!: () => void + createRoot((d) => { + dispose = d + result = fn() + }) + return { getResult: () => result, dispose } +} + +function mount(build: QueryBuild) { + const { getResult, dispose } = inRoot(() => useLiveQuery(build as any)) + return makeHandle(getResult, dispose) +} + +function mountCollection(collection: any) { + // Solid accepts a pre-created collection via an accessor. + const { getResult, dispose } = inRoot(() => useLiveQuery(() => collection)) + return makeHandle(getResult, dispose) +} + +function mountConfig(build: QueryBuild) { + // Solid accepts the config-object form via an accessor. + const { getResult, dispose } = inRoot(() => + useLiveQuery(() => ({ query: build })), + ) + return makeHandle(getResult, dispose) +} + +function mountDisabled() { + // Disabled: an accessor returning null. + const { getResult, dispose } = inRoot(() => useLiveQuery(() => null)) + return makeHandle(getResult, dispose) +} + +function mountControllable

( + build: (q: any, param: P) => any, + initial: P, +): ControllableHandle

{ + const [param, setParam] = createSignal

(initial) + const { getResult, dispose } = inRoot(() => + // Reading param() inside the query fn makes Solid recompute on change. + useLiveQuery((q: any) => build(q, param())), + ) + const handle = makeHandle(getResult, dispose) + return { + ...handle, + async setParam(next: P) { + setParam(() => next) + await settle() + }, + } +} + +const solidDriver: LiveQueryDriver = { + name: `solid`, + ops: { eq, gt, count, sum, coalesce, createOptimisticAction }, + makeSource, + makeDeferredSource, + makePrecreated, + makeErrorSource, + mount, + mountControllable, + mountCollection, + mountConfig, + mountDisabled, + // Divergence the suite surfaced: solid-db routes errors through its + // createResource/Suspense path, which THROWS (CollectionStateError) for an + // to catch, rather than exposing a readable isError flag like + // React/Vue/Svelte. Reading an errored query throws before isError can be + // observed, so the plain error-status assertion doesn't hold here. + knownGaps: [`error-status`], + features: { serverSnapshot: false, suspense: true }, +} + +runSuite(solidDriver) diff --git a/packages/svelte-db/tests/conformance.svelte.test.ts b/packages/svelte-db/tests/conformance.svelte.test.ts new file mode 100644 index 0000000000..a57709b2b9 --- /dev/null +++ b/packages/svelte-db/tests/conformance.svelte.test.ts @@ -0,0 +1,223 @@ +/** + * Svelte driver for the shared live-query conformance suite. + * + * Svelte 5 runes: each mount runs inside a persistent `$effect.root` so the + * internal `$effect` keeps updating rune state after mount; `unmount` disposes + * the root. Reads happen after `flushSync()`. Realm-sensitive pieces come from + * Svelte's `@tanstack/db`. + * + * `knownGaps` is populated empirically from the run below. + */ +import { + coalesce, + count, + createCollection, + createLiveQueryCollection, + createOptimisticAction, + eq, + gt, + sum, +} from '@tanstack/db' +import { flushSync } from 'svelte' +import { + mockSyncCollectionOptions, + mockSyncCollectionOptionsNoInitialState, +} from '../../db/tests/utils' +import { useLiveQuery } from '../src/useLiveQuery.svelte.js' +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(collection: any) { + return (type: `insert` | `update` | `delete`, value: T) => { + collection.utils.begin() + collection.utils.write({ type, value }) + collection.utils.commit() + } +} + +function makeSource( + initialData: ReadonlyArray, +): SourceHandle { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `conformance-svelte-${sourceSeq++}`, + getKey: (r) => r.id, + initialData: [...initialData], + }), + ) + const write = writer(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 { + const collection = createCollection( + mockSyncCollectionOptionsNoInitialState({ + id: `conformance-svelte-${sourceSeq++}`, + getKey: (r) => r.id, + }), + ) + collection.startSyncImmediate() + const write = writer(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-svelte-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() { + flushSync() + await new Promise((resolve) => setTimeout(resolve, 10)) + flushSync() +} + +function makeHandle(getQuery: () => any, dispose: () => void): LiveQueryHandle { + return { + current(): ConformanceResult { + const query = getQuery() + return { + data: query?.data, + status: query?.status ?? `idle`, + isReady: Boolean(query?.isReady), + isError: Boolean(query?.isError), + // svelte-db exposes no `isEnabled`; derive it from status (status-derived). + isEnabled: query?.status !== `disabled`, + } + }, + flush: settle, + async apply(fn: () => void) { + fn() + await settle() + }, + unmount() { + dispose() + }, + } +} + +function mount(build: QueryBuild) { + let query: any + const dispose = $effect.root(() => { + query = useLiveQuery(build as any) + }) + return makeHandle(() => query, dispose) +} + +function mountCollection(collection: any) { + let query: any + const dispose = $effect.root(() => { + query = useLiveQuery(collection) + }) + return makeHandle(() => query, dispose) +} + +function mountConfig(build: QueryBuild) { + let query: any + const dispose = $effect.root(() => { + query = useLiveQuery({ query: build } as any) + }) + return makeHandle(() => query, dispose) +} + +function mountDisabled() { + // Svelte's disabled convention: the query callback returns null. + let query: any + const dispose = $effect.root(() => { + query = useLiveQuery(() => null as any) + }) + return makeHandle(() => query, dispose) +} + +function mountControllable

( + build: (q: any, param: P) => any, + initial: P, +): ControllableHandle

{ + let param = $state(initial) + let query: any + const dispose = $effect.root(() => { + query = useLiveQuery((q: any) => build(q, param), [() => param]) + }) + const handle = makeHandle(() => query, dispose) + return { + ...handle, + async setParam(next: P) { + param = next + await settle() + }, + } +} + +const svelteDriver: LiveQueryDriver = { + name: `svelte`, + ops: { eq, gt, count, sum, coalesce, createOptimisticAction }, + makeSource, + makeDeferredSource, + makePrecreated, + makeErrorSource, + mount, + mountControllable, + mountCollection, + mountConfig, + mountDisabled, + // Real bug the suite caught: svelte-db's `toValue()` unwrapping (added for the + // reactive `() => collection` form) CALLS a disabled query fn like `() => null` + // as if it were a getter, unwraps it to null, and falls through to + // createLiveQueryCollection({...null}) → crash in getQueryIR. The disabled + // short-circuit is unreachable for this case, and svelte-db has no disabled + // tests. Both disabled scenarios fail until this is fixed. + knownGaps: [`disabled-explicit`, `disabled-transition`], + features: { serverSnapshot: false, suspense: false }, +} + +runSuite(svelteDriver) diff --git a/packages/vue-db/tests/conformance.test.ts b/packages/vue-db/tests/conformance.test.ts new file mode 100644 index 0000000000..432876f4f5 --- /dev/null +++ b/packages/vue-db/tests/conformance.test.ts @@ -0,0 +1,219 @@ +/** + * Vue driver for the shared live-query conformance suite. + * + * Realm-sensitive pieces (collection factories, query operators) are imported + * from Vue's `@tanstack/db` and handed to the shared scenarios. Vue composables + * run inside an `effectScope` so `unmount` can dispose them via `scope.stop()`, + * which triggers the `watchEffect` `onInvalidate` cleanup. + * + * `knownGaps` is populated empirically from the run below. + */ +import { + coalesce, + count, + createCollection, + createLiveQueryCollection, + createOptimisticAction, + eq, + gt, + sum, +} from '@tanstack/db' +import { effectScope, nextTick, ref } from 'vue' +import { + mockSyncCollectionOptions, + mockSyncCollectionOptionsNoInitialState, +} from '../../db/tests/utils' +import { useLiveQuery } from '../src/useLiveQuery' +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(collection: any) { + return (type: `insert` | `update` | `delete`, value: T) => { + collection.utils.begin() + collection.utils.write({ type, value }) + collection.utils.commit() + } +} + +function makeSource( + initialData: ReadonlyArray, +): SourceHandle { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `conformance-vue-${sourceSeq++}`, + getKey: (r) => r.id, + initialData: [...initialData], + }), + ) + const write = writer(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 { + const collection = createCollection( + mockSyncCollectionOptionsNoInitialState({ + id: `conformance-vue-${sourceSeq++}`, + getKey: (r) => r.id, + }), + ) + collection.startSyncImmediate() + const write = writer(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-vue-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 nextTick() + await new Promise((resolve) => setTimeout(resolve, 10)) +} + +function makeHandle(result: any, scope: ReturnType) { + const handle: LiveQueryHandle = { + current(): ConformanceResult { + return { + data: result.data?.value, + status: result.status?.value ?? `idle`, + isReady: Boolean(result.isReady?.value), + isError: Boolean(result.isError?.value), + // vue-db exposes no `isEnabled`; derive it from status (status-derived). + isEnabled: result.status?.value !== `disabled`, + } + }, + flush: settle, + async apply(fn: () => void) { + fn() + await settle() + }, + unmount() { + scope.stop() + }, + } + return handle +} + +function runInScope(fn: () => R): { + result: R + scope: ReturnType +} { + const scope = effectScope() + let result!: R + scope.run(() => { + result = fn() + }) + return { result, scope } +} + +function mount(build: QueryBuild) { + const { result, scope } = runInScope(() => useLiveQuery(build as any)) + return makeHandle(result, scope) +} + +function mountCollection(collection: any) { + const { result, scope } = runInScope(() => useLiveQuery(collection)) + return makeHandle(result, scope) +} + +function mountConfig(build: QueryBuild) { + const { result, scope } = runInScope(() => + useLiveQuery({ query: build } as any), + ) + return makeHandle(result, scope) +} + +function mountDisabled() { + // Vue's disabled convention: the query callback returns undefined. + const { result, scope } = runInScope(() => + useLiveQuery(() => undefined as any), + ) + return makeHandle(result, scope) +} + +function mountControllable

( + build: (q: any, param: P) => any, + initial: P, +): ControllableHandle

{ + const param = ref(initial) as { value: P } + const { result, scope } = runInScope(() => + useLiveQuery((q: any) => build(q, param.value), [() => param.value]), + ) + const handle = makeHandle(result, scope) + return { + ...handle, + async setParam(next: P) { + param.value = next + await settle() + }, + } +} + +const vueDriver: LiveQueryDriver = { + name: `vue`, + ops: { eq, gt, count, sum, coalesce, createOptimisticAction }, + makeSource, + makeDeferredSource, + makePrecreated, + makeErrorSource, + mount, + mountControllable, + mountCollection, + mountConfig, + mountDisabled, + knownGaps: [], + features: { serverSnapshot: false, suspense: false }, +} + +runSuite(vueDriver)