@cratis/pi 7.2.2 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,183 @@
1
+ # Hosting and storage for Arc for TypeScript
2
+
3
+ Verified against `Cratis/Arc.TypeScript` `main` at commit `94d398d` (tag
4
+ `v0.33.0` plus three commits): `Documentation/overview.md`, `hosts/**`,
5
+ `mongodb/getting-started.md`, `sql/getting-started.md`,
6
+ `chronicle/registration-options.md`, and the `Express`, `Fastify`, `Hono`,
7
+ `MongoDB`, `Drizzle` and `Chronicle` package exports. None of these packages is
8
+ published to npm; install them from a clone or packed tarballs.
9
+
10
+ ## The host only delivers requests
11
+
12
+ The core owns the application model; a host delivers requests to it. Commands,
13
+ queries, validators and services do not change when the host changes. Build the
14
+ application once, in its own module, so every host imports the same instance:
15
+
16
+ ```typescript
17
+ // arc.ts
18
+ import { ArcApplication } from '@cratis/arc.core';
19
+ import { Tasks } from './Features/Tasks/Tasks.js';
20
+ import { metadata } from './Features/generatedMetadata.js';
21
+
22
+ const builder = ArcApplication.createBuilder();
23
+ builder.useGeneratedMetadata(metadata);
24
+ builder.services.addSingleton(Tasks);
25
+ await builder.discover(new URL('./Features/', import.meta.url));
26
+ export const arc = await builder.build();
27
+ ```
28
+
29
+ | Host | Package and entry point | Framework peer |
30
+ | --- | --- | --- |
31
+ | Standalone Node host | `@cratis/arc.core`: `app.run()` or `app.start()` | none |
32
+ | Your own `node:http`/`node:https` server | `@cratis/arc.core`: `createArcNodeHandler(server)` | none |
33
+ | Express 5 | `@cratis/arc.express`: `app.use(cratisArc(arc))` | `express` `^5.0.0` |
34
+ | Fastify 5 | `@cratis/arc.fastify`: `app.register(cratisArc, { arc })` | `fastify` `^5.0.0` |
35
+ | Hono 4 | `@cratis/arc.hono`: `app.use(cratisArc(arc))`; Node: `serveCratisArc` | `hono` `^4.0.0`; optional `@hono/node-server` `^1.19.11` |
36
+ | Fetch API host | `@cratis/arc.core/fetch`: `app.fetch(request)` | none |
37
+
38
+ Every adapter dispatches only exact raw-path matches on a fixed internal origin
39
+ (a crafted `Host` header cannot select an operation), hands Arc the unparsed
40
+ body, and passes a cancellation signal that becomes `context.signal`. The
41
+ framework owns its listener: close it, then `await arc.dispose()`.
42
+
43
+ ## Express
44
+
45
+ ```typescript
46
+ import express from 'express';
47
+ import { cratisArc } from '@cratis/arc.express';
48
+ import { arc } from './arc.js';
49
+
50
+ const app = express();
51
+ const middleware = cratisArc(arc);
52
+ app.use(middleware); // before any body parser
53
+ app.use(express.json());
54
+ const listener = app.listen(3000, '127.0.0.1');
55
+ middleware.injectWebSocket(listener);
56
+
57
+ process.once('SIGTERM', () => {
58
+ void (async () => {
59
+ try { await middleware.close(listener); }
60
+ finally { await arc.dispose(); }
61
+ })();
62
+ });
63
+ ```
64
+
65
+ - Mount `cratisArc(arc)` **before** `express.json()` or any body parser. A parser
66
+ that runs first consumes the body, and every command answers 400
67
+ `malformedRequest`.
68
+ - Express middleware does not see Node `upgrade` requests; attach observable
69
+ WebSockets with `middleware.injectWebSocket(listener)`.
70
+ - `cratisArc(arc, native)` takes a callback returning trusted native context,
71
+ used with the `nativePrincipal: true` builder option to pass a principal your
72
+ own session or token middleware already verified.
73
+
74
+ ## Fastify
75
+
76
+ ```typescript
77
+ import Fastify from 'fastify';
78
+ import cratisArc from '@cratis/arc.fastify';
79
+ import { arc } from './arc.js';
80
+
81
+ const app = Fastify();
82
+ await app.register(cratisArc, { arc });
83
+ await app.listen({ port: 3000, host: '127.0.0.1' });
84
+ process.once('SIGTERM', () => { void app.close().then(() => arc.dispose()); });
85
+ ```
86
+
87
+ - The plugin is encapsulated, with its own catch-all raw-body parser; your
88
+ parsers are unchanged. WebSockets are on by default (`webSockets: false`
89
+ disables them).
90
+ - Fastify's `bodyLimit` (1 MiB by default) applies before Arc's
91
+ `hosting.maxBodyBytes`; raise both for larger bodies, or Fastify answers 413.
92
+ - Arc's routes exist once the app is ready (after `listen`, `ready`, or the
93
+ first `inject`). Do not register your own routes on Arc's paths.
94
+
95
+ ## Hono
96
+
97
+ ```typescript
98
+ import { Hono } from 'hono';
99
+ import { cratisArc, serveCratisArc } from '@cratis/arc.hono';
100
+ import { arc } from './arc.js';
101
+
102
+ const app = new Hono();
103
+ app.use(cratisArc(arc));
104
+ app.get('/health', context => context.text('ok'));
105
+ const hosted = await serveCratisArc(app, arc, { port: 3000, hostname: '127.0.0.1' });
106
+ process.once('SIGTERM', () => { void hosted.dispose().then(() => arc.dispose()); });
107
+ ```
108
+
109
+ - Running on Node needs the optional `@hono/node-server`. There the adapter also
110
+ checks the raw request-target. Other Hono runtimes cannot attest that raw-path
111
+ defense and are not verified, even for HTTP/SSE.
112
+ - `serveCratisArc` starts a Node listener with WebSocket upgrades; it does not
113
+ dispose `arc`.
114
+
115
+ ## Fetch API entry
116
+
117
+ `@cratis/arc.core/fetch` runs the same command and query pipelines without an
118
+ Arc-owned listener. It cannot discover files, load configuration files, serve
119
+ static files, or upgrade WebSockets — `discover()` fails on its builder.
120
+ Register artifacts explicitly:
121
+
122
+ ```typescript
123
+ import { ArcApplication } from '@cratis/arc.core/fetch';
124
+ import { Ping } from './Ping.js';
125
+ import { Status } from './Status.js';
126
+
127
+ const builder = ArcApplication.createBuilder();
128
+ builder.add(Ping, Status);
129
+ export const app = await builder.build();
130
+ // host: (request: Request) => app.fetch(request)
131
+ ```
132
+
133
+ Import `command`, `query`, `readModel` from `@cratis/arc.core/fetch` in artifacts
134
+ served this way. Arc targets **Node-compatible** servers: Chronicle uses gRPC,
135
+ MongoDB uses TCP, and live queries need long-lived SSE/WebSocket connections.
136
+ Non-Node edge runtimes are not supported hosts for the framework or these
137
+ integrations. Bun has a smoke check only. The host owns ingress limits, trusted
138
+ principal metadata, and cancelling long-lived streams on disconnect.
139
+
140
+ ## Observable-query WebSockets from a dev server
141
+
142
+ A browser WebSocket from a frontend dev server on another port is cross-origin
143
+ and is refused silently. Add that origin to `query.allowedOrigins`:
144
+
145
+ ```typescript
146
+ ArcApplication.createBuilder({ query: { allowedOrigins: ['http://127.0.0.1:5173'] } });
147
+ ```
148
+
149
+ ## Storage integrations
150
+
151
+ Arc requires no database and no event store. Each integration is optional and
152
+ adds a builder method when imported; the exported function form is equivalent.
153
+
154
+ | Integration | Register | Inject |
155
+ | --- | --- | --- |
156
+ | MongoDB (`@cratis/arc.mongodb`, peer `mongodb` `^6.21.0`) | `import '@cratis/arc.mongodb'; builder.withMongoDB({ client, databaseNameResolver, readModels: [TaskRecord] })` or `withMongoDB(builder, options)` | `service(mongoCollection(TaskRecord))` in `@query`, or `@inject(mongoCollection(TaskRecord))`; `commandReadModel(TaskRecord)` in commands |
157
+ | SQL through Drizzle (`@cratis/arc.drizzle`, peer `drizzle-orm` `^0.45.0`) | `import '@cratis/arc.drizzle'; builder.withDrizzle({ dialect: DrizzleDialect.SQLite, database, readModels: [{ type: TaskRecord, table: tasks }] })` | `service(drizzleReadModel(TaskRecord))` for read-only query handles; `@inject(drizzleDatabase<SQLJsDatabase>())` for a writable handle in commands; `commandReadModel(TaskRecord)` |
158
+ | Chronicle (`@cratis/arc.chronicle`, experimental, peer `@cratis/chronicle` `^6.7.0`) | `import '@cratis/arc.chronicle'; builder.withChronicle({ connectionString, eventStore })` or `withChronicle(builder, options)` | Commands return events; queries take `service(ChronicleReadModels)`; `commandReadModel(Type)` |
159
+
160
+ - **MongoDB**: list every injected model in `readModels`; a model left out has
161
+ no collection token. `@key()` marks the field stored as `_id` (otherwise a
162
+ field named `id`). Only `server` and `database` bind from `Cratis:MongoDB`
163
+ configuration. Change-stream observation needs a replica set.
164
+ - **Drizzle**: `withDrizzle` never creates or changes tables — own the schema
165
+ with migrations. Every SQL access needs a tenant (`A tenant is required for
166
+ Drizzle access` otherwise); a single-database setup resolves one fixed tenant.
167
+ Command read-model injection needs exactly one column-level `.primaryKey()`
168
+ declared as an Arc `@field`.
169
+ - **Chronicle**: `withChronicle` needs `eventStore` and exactly one of
170
+ `connectionString` or `client` (`Chronicle requires eventStore and exactly one
171
+ of connectionString or client`). It reads `Cratis:Chronicle` `connectionString`
172
+ and `eventStore` from configuration; code wins. Call it before or after
173
+ `discover()`, but before `add()` for Chronicle-only artifacts. With
174
+ `connectionString`, Arc owns and disposes the SDK client; a caller-owned
175
+ `client` is never disposed by Arc. Every append and read uses the current
176
+ tenant as the Chronicle namespace (`Default` when none). A kernel that is not
177
+ reachable makes Chronicle-backed requests wait, not fail.
178
+ `chronicle://localhost:35000` without credentials uses the development client —
179
+ local kernels only.
180
+ - `@cratis/cratis` (experimental) registers Arc and Chronicle together through
181
+ `CratisApplication.createBuilder()` and `builder.addCratis(...)`. Unlike C#
182
+ `AddCratis`, it installs no authentication handler; protected routes still
183
+ need one you choose.
@@ -0,0 +1,15 @@
1
+ {
2
+ "skill": "cratis-arc-command-typescript",
3
+ "input": "Add a command that registers an author to an Arc for TypeScript server and append an event to Chronicle.",
4
+ "fixtures": [],
5
+ "assertions": [
6
+ { "kind": "skill-contains", "value": "**None of the server packages is published to npm.**" },
7
+ { "kind": "skill-contains", "value": "**`@cratis/arc` is a different package.**" },
8
+ { "kind": "skill-contains", "value": "ArcApplication.createBuilder()" },
9
+ { "kind": "skill-contains", "value": "**No key is inferred from an unmarked `id`\nfield.**" },
10
+ { "kind": "skill-contains", "value": "Authorization belongs on the **class**." },
11
+ { "kind": "skill-contains", "value": "is created through its **constructor** in `handle()`" },
12
+ { "kind": "skill-contains", "value": "`--check-metadata`" },
13
+ { "kind": "skill-contains", "value": "CommandScenario.for(RegisterTask, RegisterTaskValidator)" }
14
+ ]
15
+ }
@@ -0,0 +1,2 @@
1
+ Copyright (c) Cratis. All rights reserved.
2
+ Licensed under the MIT license. See LICENSE file in the project root for full license information.
@@ -0,0 +1,350 @@
1
+ ---
2
+ name: cratis-arc-query-typescript
3
+ description: Add a query to a Cratis Arc read model in a Node.js TypeScript server with Arc for TypeScript — the @readModel() class and its static @query() methods, argument/service/queryOptions binding with generated metadata or explicit descriptors, return shapes and absence, GET and the HTTP QUERY method, paging and sorting with queryPage, observable queries backed by RxJS BehaviorSubject/Subject/Observable over SSE and WebSocket, storage-backed queries through MongoDB, Drizzle or ChronicleReadModels, query authorization, and QueryScenario/ObservableQueryScenario specs. Use when exposing read-model data from an Arc for TypeScript server. Do not use for the .NET or Kotlin/Java Arc query shapes, for command definition, for consuming generated query proxies in React, or for how Chronicle events populate the read model.
4
+ license: MIT
5
+ ---
6
+
7
+ # Add a query to an Arc read model in TypeScript (Node.js server)
8
+
9
+ In Arc for TypeScript a read model is a decorated class, and its reads are
10
+ **static** methods on it marked `@query()`. Each query becomes a route; Arc binds
11
+ the arguments, resolves services, pages and sorts, and wraps every answer in the
12
+ same `QueryResult` envelope. There is no controller to write.
13
+
14
+ Arc for TypeScript is a **source preview**: `@cratis/arc.core` and the other
15
+ server packages are not published to npm, and the API may change. `@cratis/arc`
16
+ is the separate, published **client** that generated query proxies import in a
17
+ frontend. Project setup, the builder, and hosts are in
18
+ `cratis-arc-command-typescript`.
19
+
20
+ ## Verified product sources
21
+
22
+ Verified against `Cratis/Arc.TypeScript` `main` at commit `94d398d` (tag
23
+ `v0.33.0` plus three commits), reading
24
+ `Source/Core/queries/index.ts`, `Documentation/queries/**`,
25
+ `Documentation/mongodb/**`, `Documentation/sql/**`,
26
+ `Documentation/testing/queries.md` and `observable-queries.md`, and
27
+ `Samples/Tasks` / `Samples/Library`. RxJS `^7.8.2` is an optional peer of
28
+ `@cratis/arc.core`, required only for RxJS sources.
29
+
30
+ ## Declare a read model and its queries
31
+
32
+ `Samples/Tasks/Features/Tasks/Listing/Listing.ts`:
33
+
34
+ ```typescript
35
+ import { field } from '@cratis/fundamentals';
36
+ import { query, readModel, service } from '@cratis/arc.core';
37
+ import type { BehaviorSubject } from 'rxjs';
38
+ import { TaskId } from '../TaskId.js';
39
+ import { TaskTitle } from '../TaskTitle.js';
40
+ import { Tasks } from '../Tasks.js';
41
+
42
+ @readModel()
43
+ export class TaskItem {
44
+ @field(TaskId) id!: TaskId;
45
+ @field(TaskTitle) title!: TaskTitle;
46
+
47
+ @query(service(Tasks))
48
+ static allTasks(tasks: Tasks): TaskItem[] { return tasks.all(); }
49
+
50
+ @query()
51
+ static taskById(id: TaskId, tasks: Tasks): TaskItem | undefined { return tasks.byId(id); }
52
+
53
+ @query()
54
+ static observeAllTasks(tasks: Tasks): BehaviorSubject<TaskItem[]> { return tasks.observeAll(); }
55
+ }
56
+ ```
57
+
58
+ Arc serves a method only when all three hold:
59
+
60
+ - the class carries `@readModel()` (from `@cratis/arc.core`) and is exported under
61
+ the discovery root,
62
+ - the method is `static`,
63
+ - the method carries `@query(...)`.
64
+
65
+ Any other static method is an ordinary helper with no route. `@query()` on an
66
+ instance or private method throws when the class loads; `@roles`, `@authorize`,
67
+ `@allowAnonymous` or `@path` on a static method without `@query()` fails at
68
+ `build()`. The `@field` declarations (from `@cratis/fundamentals`) describe the
69
+ shape Arc encodes on the way out and that the proxy generator uses for the
70
+ frontend model — keep one on every wire property.
71
+
72
+ ## Bind every parameter
73
+
74
+ Each parameter is one of three kinds:
75
+
76
+ | Descriptor | Binds |
77
+ | --- | --- |
78
+ | `argument(name, Type, { optional?, elementType? })` | A named argument from the query string or `QUERY` body |
79
+ | `service(Token)` | A service from the request's scope (or the subscription's, for an observable query) |
80
+ | `queryOptions()` | The request's paging and sorting, as `QueryOptions` |
81
+
82
+ TypeScript erases parameter types, so Arc learns them one of two ways:
83
+
84
+ - **Generated artifact metadata** (the samples): with `builder.useGeneratedMetadata(metadata)`
85
+ installed before discovery, bare `@query()` is enough. A primitive, concept, or
86
+ array of them becomes a named argument; a concrete class becomes a service. That
87
+ is how `taskById(id: TaskId, tasks: Tasks)` binds.
88
+ - **Explicit descriptors**, one per parameter in signature order:
89
+ `@query(argument('id', TaskId), service(Tasks))`. Explicit descriptors always
90
+ win over generated ones.
91
+
92
+ A parameter nobody describes fails at `build()` with `Unbound parameters on
93
+ <Type>.<method>` (or `Missing parameter metadata` for a bare `@query()` without
94
+ metadata). TypeScript error TS1241 on `@query(...)` means the descriptors do not
95
+ match the parameters. Regenerate metadata after every change and gate CI with
96
+ `arc-proxygenerator --check-metadata`.
97
+
98
+ ### Arguments
99
+
100
+ ```typescript
101
+ @query(argument('tags', Array, { elementType: String }),
102
+ argument('limit', Number, { optional: true }))
103
+ static search(tags: string[], limit: number | undefined): Item[] { /* ... */ }
104
+ ```
105
+
106
+ - Type an optional argument `number | undefined`, not `limit?: number`.
107
+ - GET names match case-insensitively; numbers, booleans, `Guid` and concepts are
108
+ converted from text. An undeclared argument, an invalid value, or a repeated
109
+ non-array key answers 400 `malformedRequest`.
110
+ - `page`, `pageSize`, `sortBy`, `sortDirection` are reserved and never reach your
111
+ arguments.
112
+ - A query cannot take generic type arguments from HTTP.
113
+
114
+ ## Return what the caller should see
115
+
116
+ | The method returns | The caller gets |
117
+ | --- | --- |
118
+ | An array of the model | `data` is the array; Arc pages and sorts it in memory on request |
119
+ | One model | `data` is the object |
120
+ | `undefined` or `null` | HTTP 200, `isSuccess: true`, **no** `data` property |
121
+ | `queryPage(items, totalItems, sorting?)` | `data` is `items`; `paging` reports your totals |
122
+ | An observable source | A live query (below) |
123
+
124
+ - A method can be `async` or return a promise of any of these.
125
+ - **Absence is an answer, not an error.** Declare `TaskItem | undefined` when
126
+ absence is possible — with generated metadata, returning `undefined` from a
127
+ method declared `TaskItem` fails.
128
+ - **Let storage failures throw.** A thrown error is a failed result with status
129
+ 500 (message redacted unless exception details are enabled). Never catch and
130
+ return `undefined` or `[]`, or the caller cannot tell "none" from "down".
131
+ - Concepts are encoded to their primitive: a `TaskId` goes out as a UUID string.
132
+
133
+ ## Route and HTTP method
134
+
135
+ The route is `/api/<discovery-namespace>/<method-name>` in kebab case —
136
+ `TaskItem.allTasks` in `Features/Tasks/Listing` is `GET /api/tasks/listing/all-tasks`.
137
+ `@path('/api/custom')` on the method overrides it; `@readModel({ namespace })`
138
+ pins the namespace across folder moves.
139
+
140
+ Every query route accepts GET and the HTTP `QUERY` method, whose JSON body is
141
+ `{ "arguments": {...}, "paging": { "page", "pageSize" }, "sorting": { "field", "direction" } }`.
142
+ `QUERY` answers carry `Cache-Control: no-store`. Use it for structured arguments.
143
+ `generatedApis: { enableQueryHttpMethod: false }` turns it off (405 with
144
+ `Allow: GET`). `@query({ httpMethod: QueryHttpMethod.Query })` only sets the
145
+ generated client's preference; it does not change the server. OpenAPI describes
146
+ queries as GET only.
147
+
148
+ ## Page and sort
149
+
150
+ - An array result is sorted, then paged, in memory: `?pageSize=2&page=0&sortBy=title&sortDirection=desc`.
151
+ `page` is zero-based. `pageSize` of 0 or less, or a negative page, answers
152
+ 400. Sorting in memory requires the field on every item; a non-array result
153
+ asked to page or sort answers 400.
154
+ - To cut the page in the data source, add `queryOptions()` and return
155
+ `queryPage(items, totalItems)`. `items` must be exactly the requested page.
156
+ If the request asked for sorting, pass the applied sort as the third argument —
157
+ otherwise 400; Arc never re-sorts a page it did not cut.
158
+ - A provider can throw `QueryPagingRequired(maxPageSize)` or `InvalidQuerySort`
159
+ to report its own limits as 400 validation results.
160
+ - The MongoDB and Drizzle integrations' `queryPage(...)` push count, sort and
161
+ paging into the database for you. Server-side paging semantics shared with
162
+ Arc .NET are in `cratis-arc-query-paging`.
163
+
164
+ ## Declare an observable query
165
+
166
+ Return a live source and the same route serves a snapshot on GET and streams
167
+ changes to subscribers:
168
+
169
+ ```typescript
170
+ @query()
171
+ static observeAllTasks(tasks: Tasks): BehaviorSubject<TaskItem[]> { return tasks.observeAll(); }
172
+ ```
173
+
174
+ Arc must know a query is observable **before** registration. Generated metadata
175
+ infers it from the declared return type; without metadata, say so explicitly:
176
+ `@query({ observable: true }, service(Tasks))`. A snapshot query that returns a
177
+ live source anyway fails at run time.
178
+
179
+ | Source | Snapshot GET |
180
+ | --- | --- |
181
+ | RxJS `BehaviorSubject<T>` | 200 immediately with the current value; new subscribers receive it first |
182
+ | RxJS `Subject<T>` / `Observable<T>` | 202 with `isReady: false` until the first emission; `?waitForFirstResult=true` (optionally `waitForFirstResultTimeout=<seconds>`, max 120) waits |
183
+ | RxJS `ReplaySubject<T>` | 202 even after an emission; a waiting GET receives the buffered value |
184
+ | `AsyncIterable<T>` | No current value until the first item |
185
+ | `CurrentValueSubject<T>` | Deprecated — use `BehaviorSubject` or `Subject` |
186
+
187
+ - Use `BehaviorSubject` when there is always a current value. Emit `undefined`
188
+ for "looked, nothing there" (200, ready, no `data`); stay silent only when you
189
+ genuinely do not know yet (202). Let errors be errors — a source error ends the
190
+ subscription with a failed result.
191
+ - Each subscription runs the method **once**, after authorization and
192
+ validation, with its own service scope, disposed when it ends.
193
+ `currentContext()` from `@cratis/arc.core` returns the subscription's context;
194
+ its `signal` aborts when the subscriber leaves.
195
+ - Paging and sorting apply to **every** emission, in memory. An observable query
196
+ cannot return `queryPage`; narrow what the source emits instead, or use a
197
+ database-backed observation such as MongoDB change streams.
198
+ - Transports: `Accept: text/event-stream` on the route (direct SSE, one
199
+ `data: <QueryResult>` frame per change), a direct WebSocket upgrade
200
+ (`{"type":"Data","data":<QueryResult>}` frames, `Ping`/`Pong`), or the
201
+ multiplexed hub that generated `@cratis/arc` proxies use. Framework adapters
202
+ mount WebSockets separately; a browser on a dev-server origin needs that origin
203
+ in `query.allowedOrigins`. Terminal inspection is `cratis-arc-observable-query-http`.
204
+
205
+ ## Authorize a query
206
+
207
+ `@authorize()`, `@authorize({ policy?, roles?, schemes? })`, `@roles(...)` and
208
+ `@allowAnonymous()` work on the read-model class and on `@query()` methods. An
209
+ explicit method declaration **replaces** the class declaration; without one, the
210
+ method inherits it — so a class `@allowAnonymous()` does not bypass a method
211
+ `@roles('Reader')`. A denied caller never reaches the method (`isAuthorized:
212
+ false`).
213
+
214
+ - A role decides who may call, not which rows they see. Filter rows by the
215
+ caller inside the method or source, from `currentContext()?.principal`, never
216
+ from a caller-supplied argument and never on the client.
217
+ - For an observable query, authorization runs **once**, when the subscription
218
+ opens. A revoked role does not close it; add an observable emission guard when
219
+ access must be re-checked while the stream runs.
220
+
221
+ ## Serve stored read models
222
+
223
+ The storage integrations register tenant-scoped service tokens; a query never
224
+ chooses a database itself.
225
+
226
+ ```typescript
227
+ // MongoDB — register with builder.withMongoDB({ ..., readModels: [TaskRecord] })
228
+ import { query, queryOptions, readModel, service, type QueryOptions } from '@cratis/arc.core';
229
+ import { mongoCollection, type MongoCollection } from '@cratis/arc.mongodb';
230
+
231
+ const tasks = mongoCollection(TaskRecord);
232
+
233
+ @readModel()
234
+ export class TaskQueries {
235
+ @query(service(tasks))
236
+ static async all(items: MongoCollection<TaskRecord>): Promise<TaskRecord[]> { return items.find(); }
237
+
238
+ @query(service(tasks), queryOptions())
239
+ static async page(items: MongoCollection<TaskRecord>, options: QueryOptions) { return items.queryPage({}, options); }
240
+
241
+ @query({ observable: true }, service(tasks))
242
+ static changes(items: MongoCollection<TaskRecord>) { return items.observe(); }
243
+ }
244
+ ```
245
+
246
+ ```typescript
247
+ // Drizzle — register with builder.withDrizzle({ dialect, database, readModels: [{ type: TaskRecord, table: tasks }] })
248
+ import { drizzleReadModel, type DrizzleReadModels } from '@cratis/arc.drizzle';
249
+
250
+ @readModel()
251
+ export class TaskQueries {
252
+ @query(service(drizzleReadModel(TaskRecord)), queryOptions())
253
+ static page(tasks: DrizzleReadModels<TaskRecord>, options: QueryOptions) { return tasks.queryPage(undefined, options); }
254
+ }
255
+ ```
256
+
257
+ ```typescript
258
+ // Chronicle (experimental) — Samples/Library/Features/Authors/Listing/Listing.ts
259
+ import { ChronicleReadModels } from '@cratis/arc.chronicle';
260
+ import { fromEvent } from '@cratis/chronicle/projections';
261
+
262
+ @readModel()
263
+ @fromEvent(AuthorRegistered)
264
+ export class Author {
265
+ @field(AuthorId) id!: AuthorId;
266
+ @field(AuthorName) name!: AuthorName;
267
+
268
+ @query({ observable: true }, service(ChronicleReadModels))
269
+ static allAuthors(models: ChronicleReadModels): Observable<Author[]> {
270
+ return models.observeAll(Author, author => author.id.toString());
271
+ }
272
+ }
273
+ ```
274
+
275
+ - MongoDB: every injected model is listed in `withMongoDB`'s `readModels`;
276
+ `observe()` needs a replica set. Build owner filters from the verified
277
+ principal, never from a caller-provided filter object.
278
+ - Drizzle: `drizzleReadModel(Model)` is a read-only handle; writes belong in
279
+ commands through `drizzleDatabase()`.
280
+ - Chronicle: `ChronicleReadModels` offers `getAll`, `getById`, `observeAll`,
281
+ `observeById` for the current tenant's namespace. Projecting happens after the
282
+ append, so a query right after a command can be stale; a live query updates
283
+ when the projection catches up. Projection authoring is
284
+ `cratis-chronicle-client-typescript`.
285
+
286
+ ## Specify the query
287
+
288
+ `@cratis/arc.testing` runs the real query pipeline in-process. From
289
+ `Samples/Tasks`:
290
+
291
+ ```typescript
292
+ import { ObservableQueryScenario, QueryScenario } from '@cratis/arc.testing';
293
+ import { Tasks } from '../../../Tasks.js';
294
+ import { TaskItem } from '../../Listing.js';
295
+ import { metadata } from '../../../../generatedMetadata.js';
296
+
297
+ export class a_task_listing {
298
+ tasks = new Tasks();
299
+ query = QueryScenario.for<{ id: string; title: string }[]>(TaskItem, 'allTasks');
300
+ observable = ObservableQueryScenario.for<{ id: string; title: string }[]>(TaskItem, 'observeAllTasks');
301
+
302
+ constructor() {
303
+ this.query.extend(builder => builder.useGeneratedMetadata(metadata));
304
+ this.observable.extend(builder => builder.useGeneratedMetadata(metadata));
305
+ this.query.services.addSingleton(Tasks, this.tasks);
306
+ this.observable.services.addSingleton(Tasks, this.tasks);
307
+ }
308
+ }
309
+ ```
310
+
311
+ - `QueryScenario.for<T>(ReadModel, 'methodName', ...artifacts)` — `T` is the
312
+ **wire** shape (`data` is JSON-shaped; concepts arrive as strings), not a
313
+ rehydrated model.
314
+ - `perform(arguments, { paging, sorting }?)` returns the actual `QueryResult`,
315
+ e.g. `perform({}, { sorting: { field: 'title', direction: SortDirection.Ascending }, paging: { page: 0, pageSize: 1 } })`.
316
+ - `ObservableQueryScenario.collect(count, timeoutMs = 5000, arguments?, options?)`
317
+ returns `{ emissions, completed, rejection }`; a rejected subscription lands in
318
+ `rejection`, not `emissions`. Always assert the emission count.
319
+ - `withContext({ principal, tenantId })` sets a trusted caller for authorization
320
+ specs. Register fakes before the first call; dispose every scenario.
321
+
322
+ ## Route near misses
323
+
324
+ - Defining or changing a command, the builder, or hosts:
325
+ `cratis-arc-command-typescript`.
326
+ - A query-argument rejection rule (`QueryValidator`, arguments model):
327
+ `cratis-arc-validation-typescript`.
328
+ - Calling the generated query proxies from React: `cratis-arc-react-page`.
329
+ - Chronicle projections, reducers and reactors in TypeScript:
330
+ `cratis-chronicle-client-typescript`.
331
+ - Inspecting an observable query with curl: `cratis-arc-observable-query-http`.
332
+ - The .NET query shape: `cratis-arc-query-paging` and the vertical-slice rules;
333
+ Kotlin/Java: `cratis-arc-query-kotlin`.
334
+
335
+ ## Verify
336
+
337
+ - Every query is a public `static` method with `@query(...)` on an exported
338
+ `@readModel()` class; no helper is exposed by accident.
339
+ - Every parameter is bound — by generated metadata that `--check-metadata`
340
+ confirms is current, or by descriptors in signature order.
341
+ - Declared return types admit absence where it is possible, and storage failures
342
+ throw rather than returning empty data.
343
+ - Observable queries are declared observable (by metadata or
344
+ `{ observable: true }`) and use a `BehaviorSubject` when a snapshot GET must
345
+ answer 200.
346
+ - Row-level filtering uses the verified principal inside the source.
347
+ - `QueryScenario` / `ObservableQueryScenario` specs pass, including paging,
348
+ absence, and denied callers.
349
+ - Guidance never claims the server packages are on npm, and never confuses
350
+ `@cratis/arc` (the client) with `@cratis/arc.core` (the server).
@@ -0,0 +1,14 @@
1
+ {
2
+ "skill": "cratis-arc-query-typescript",
3
+ "input": "Expose a live list of tasks from an Arc for TypeScript read model.",
4
+ "fixtures": [],
5
+ "assertions": [
6
+ { "kind": "skill-contains", "value": "is a **source preview**" },
7
+ { "kind": "skill-contains", "value": "- the method is `static`," },
8
+ { "kind": "skill-contains", "value": "**Absence is an answer, not an error.**" },
9
+ { "kind": "skill-contains", "value": "`@query({ observable: true }, service(Tasks))`" },
10
+ { "kind": "skill-contains", "value": "| RxJS `BehaviorSubject<T>` | 200 immediately" },
11
+ { "kind": "skill-contains", "value": "authorization runs **once**, when the subscription\n opens" },
12
+ { "kind": "skill-contains", "value": "QueryScenario.for<T>(ReadModel, 'methodName', ...artifacts)" }
13
+ ]
14
+ }
@@ -0,0 +1,2 @@
1
+ Copyright (c) Cratis. All rights reserved.
2
+ Licensed under the MIT license. See LICENSE file in the project root for full license information.