@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.
- package/package/corpus/rules/profiles.md +23 -0
- package/package/corpus/skills/cratis-arc-command-typescript/LICENSE +2 -0
- package/package/corpus/skills/cratis-arc-command-typescript/SKILL.md +495 -0
- package/package/corpus/skills/cratis-arc-command-typescript/references/chronicle-events.md +141 -0
- package/package/corpus/skills/cratis-arc-command-typescript/references/hosting-and-storage.md +183 -0
- package/package/corpus/skills/cratis-arc-command-typescript/verification.json +15 -0
- package/package/corpus/skills/cratis-arc-query-typescript/LICENSE +2 -0
- package/package/corpus/skills/cratis-arc-query-typescript/SKILL.md +350 -0
- package/package/corpus/skills/cratis-arc-query-typescript/verification.json +14 -0
- package/package/corpus/skills/cratis-arc-validation-typescript/LICENSE +2 -0
- package/package/corpus/skills/cratis-arc-validation-typescript/SKILL.md +320 -0
- package/package/corpus/skills/cratis-arc-validation-typescript/verification.json +14 -0
- package/package/profile-catalog.json +24 -0
- package/package.json +1 -1
|
@@ -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,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
|
+
}
|