Testing queries
QueryScenario selects one decorated @query method on a read model and performs it through the real query pipeline.
Perform a query with paging and sorting
Section titled “Perform a query with paging and sorting”The Tasks sample registers two tasks and asks for the first page, sorted by title:
import { SortDirection } from '@cratis/arc.core';import { given } from '@cratis/arc.testing';import { TaskId } from '../../../TaskId.js';import { TaskTitle } from '../../../TaskTitle.js';import { a_task_listing } from '../given/a_task_listing.js';
describe('when performing the tasks query with sorting and paging', given(a_task_listing, context => { let result: Awaited<ReturnType<typeof context.query.perform>>; beforeAll(() => { context.tasks.register(TaskId.create(), new TaskTitle('Zebra')); context.tasks.register(TaskId.create(), new TaskTitle('Apple')); }); beforeEach(async () => { result = await context.query.perform({}, { sorting: { field: 'title', direction: SortDirection.Ascending }, paging: { page: 0, pageSize: 1 } }); }); afterAll(async () => { await context.query.dispose(); await context.observable.dispose(); }); it('should return the first sorted wire value', () => { result.isSuccess.should.equal(true); String(result.data?.[0]?.title).should.equal('Apple'); }); it('should report the total number of items', () => { result.paging.totalItems.should.equal(2); });}));Its context creates the scenarios:
import { ObservableQueryScenario, QueryScenario } from '@cratis/arc.testing';import { Tasks } from '../../../Tasks.js';import { TaskItem } from '../../Listing.js';import { metadata } from '../../../../generatedMetadata.js';
export class a_task_listing { tasks = new Tasks(); query = QueryScenario.for<{ id: string; title: string }[]>(TaskItem, 'allTasks'); observable = ObservableQueryScenario.for<{ id: string; title: string }[]>(TaskItem, 'observeAllTasks');
constructor() { this.query.extend(builder => builder.useGeneratedMetadata(metadata)); this.observable.extend(builder => builder.useGeneratedMetadata(metadata)); this.query.services.addSingleton(Tasks, this.tasks); this.observable.services.addSingleton(Tasks, this.tasks); }}How it works
Section titled “How it works”QueryScenario.for<T>(ReadModel, 'methodName', ...artifacts)selects the query.Tdescribes the wire data, not a rehydrated read-model instance:datais JSON-shaped, so concepts arrive as strings.perform(arguments, options?)takes named arguments, such as{ id: TaskId.create() }, and optional{ paging, sorting }.- The result is the actual
QueryResult. Queries always useWarningas the allowed severity. withContext({ principal, tenantId })sets a trusted identity, as for commands.
Query Chronicle read models in memory
Section titled “Query Chronicle read models in memory”ChronicleQueryScenario runs a snapshot query through the same Arc query pipeline, with tenant-scoped in-memory Chronicle state. A query can inject ChronicleReadModels and call getById or findInstanceById to read a seeded source. Register the query’s event, reducer, and read-model types as artifacts:
import { field } from '@cratis/fundamentals';import { eventType } from '@cratis/chronicle/events';import { reducer } from '@cratis/chronicle/reducers';import { readModel as chronicleReadModel } from '@cratis/chronicle/readModels';import { argument, query, readModel, service } from '@cratis/arc.core';import { ChronicleReadModels } from '@cratis/arc.chronicle';import { ChronicleQueryScenario } from '@cratis/arc.chronicle/testing';
@eventType() class BalanceChanged { @field(Number) amount: number; constructor(amount = 0) { this.amount = amount; } }@chronicleReadModel() class AccountBalance { @field(Number) amount = 0; }@reducer('account-balance-query-reducer', undefined, AccountBalance)class BalanceReducer { balanceChanged(event: BalanceChanged, current?: AccountBalance): AccountBalance { return { amount: (current?.amount ?? 0) + event.amount }; }}@readModel() class AccountQueries { @query(argument('id', String), service(ChronicleReadModels)) static byId(id: string, models: ChronicleReadModels): Promise<AccountBalance | null> { return models.getById(AccountBalance, id); }}
const scenario = ChronicleQueryScenario.for<{ amount: number }>(AccountQueries, 'byId', AccountBalance, BalanceChanged, BalanceReducer);scenario.given.forEventSource('account-1').events(new BalanceChanged(25));const result = await scenario.perform({ id: 'account-1' });result.data!.amount.should.equal(25);await scenario.dispose();given.forEventSource(id, tenant?).readModel(instance) pins a value for that source, read-model type, and tenant; pins win over reducer or projection history. givenReadModel(Type, id, instance, tenant?) can also pin by explicit type. With no pin and no seeded history for that source, or no projection or reducer candidates for that model, a keyed lookup returns null. The tenant defaults to the current trusted scenario.context.tenantId (or Default) when .events() or .readModel() is called; withContext({ tenantId }) selects the tenant used by the query. Only keyed lookups are backed in memory. getAll and store watches reject with a kernel hint. Declared observable queries cannot be performed by ChronicleQueryScenario: observation needs ChronicleKernelScenario because ObservableQueryScenario has no in-memory Chronicle wiring.
Reducer seeding needs @cratis/chronicle 6.14.0 or later. With SDK 6.19.0 or later, the scenario evaluates supported flat projections in-process from seeded events. On older SDKs, a seeded lookup with a registered projection that omits its read-model type fails explicitly with @cratis/chronicle >= 6.19.0 and suggests @projection('', ReadModel) because Arc cannot determine the association without the evaluator. Unsupported definitions throw the SDK’s UnsupportedProjectionOperation; use ChronicleKernelScenario for those, for observation, or for observer-driven updates after a command append. See Testing Chronicle commands for the same seeding rules.
Snapshot versus streaming
Section titled “Snapshot versus streaming”QueryScenario.perform() rejects decorated @query({ observable: true }) methods instead of returning a current snapshot as ArcServer.performQuery() does. For non-Chronicle observable queries, use ObservableQueryScenario. ChronicleQueryScenario.perform() also rejects declared observable queries and directs you to ChronicleKernelScenario for observation.
A method declared as a snapshot that unexpectedly returns a subscribable or async iterable is rejected by the core query pipeline. Whether called over HTTP or through QueryScenario, Arc attempts release through the first hook on the returned object, in order: Symbol.asyncDispose, Symbol.dispose, dispose(), close(), or return() if the object itself has both next() and return(). Arc never releases an instance owned by its service container; its scope or registry handles disposal. It never subscribes or calls [Symbol.asyncIterator]() or [Symbol.iterator]() during cleanup; a cold RxJS Observable or shared Subject with no release hook is rejected but left untouched. Cleanup has its own deadline even when the request is canceled. A release failure (including a timeout) is reported alongside the snapshot rejection; a failure after the deadline is logged. Custom scenario harnesses can use snapshotStreamSource(result) from @cratis/arc.core to inspect the rejected source; Arc has already attempted release if the source owns a hook, so do not release it a second time.