@cratis/pi 7.58.0 → 7.60.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/agents/backend-developer.md +1 -0
- package/package/corpus/agents/code-reviewer.md +1 -1
- package/package/corpus/agents/performance-reviewer.md +1 -1
- package/package/corpus/harnesses/copilot/instructions/framework.instructions.md +1 -1
- package/package/corpus/harnesses/copilot/instructions/mongodb.instructions.md +67 -0
- package/package/corpus/harnesses/copilot/instructions/react.instructions.md +2 -2
- package/package/corpus/harnesses/copilot/instructions/vertical-slices.instructions.md +3 -2
- package/package/corpus/rules/framework.md +1 -1
- package/package/corpus/rules/general.md +1 -0
- package/package/corpus/rules/mongodb.md +66 -0
- package/package/corpus/rules/react.md +2 -2
- package/package/corpus/rules/vertical-slices.md +3 -2
- package/package/corpus/skills/cratis-arc-query-paging/SKILL.md +86 -47
- package/package/corpus/skills/cratis-arc-react-page/references/queries-and-commands.md +3 -2
- package/package/corpus/skills/cratis-chronicle-read-model/references/queries.md +12 -1
- package/package/corpus/skills/cratis-code-review/SKILL.md +3 -2
- package/package/corpus/skills/cratis-performance-review/SKILL.md +5 -2
- package/package.json +1 -1
|
@@ -30,6 +30,7 @@ Select from these canonical rules in `.cratis/ai/rules/` only after applying the
|
|
|
30
30
|
- `csharp.md` — C# conventions
|
|
31
31
|
- `concepts.md` — `ConceptAs<T>` / `EventSourceId<T>`
|
|
32
32
|
- `efcore.md` — EF Core read models (only if the project uses EF Core)
|
|
33
|
+
- `mongodb.md` — MongoDB queries: native driver API, never LINQ; Arc owns paging and sorting (only if the project uses MongoDB)
|
|
33
34
|
- `general.md` — the operating manual
|
|
34
35
|
|
|
35
36
|
Load **cratis-application-slice-conformance** when it is installed; read the skill for the slice type (`cratis-arc-command`, `cratis-chronicle-read-model`, `cratis-chronicle-projection`, `cratis-chronicle-reactor`) before writing the artifact it covers.
|
|
@@ -25,7 +25,7 @@ This is a read-only review role: propose corrections and refactors in the report
|
|
|
25
25
|
You are the **Code Reviewer** for Cratis-based projects.
|
|
26
26
|
Your responsibility is to review all changed files and ensure they meet project standards before merge.
|
|
27
27
|
|
|
28
|
-
Select only diff-relevant, profile-applicable canonical rules in `.cratis/ai/rules/` (and `general.md`): `vertical-slices.md`, `csharp.md`, `code-quality.md` (+ `.csharp`/`.typescript`), `specs.md` (+ `.csharp`/`.typescript`), `frontend-testing.md`, `typescript.md`, `react.md`, `components.md`, `dialogs.md`, `frontend-quality.md`, `concepts.md`, `efcore.md`/`efcore.specs.md`.
|
|
28
|
+
Select only diff-relevant, profile-applicable canonical rules in `.cratis/ai/rules/` (and `general.md`): `vertical-slices.md`, `csharp.md`, `code-quality.md` (+ `.csharp`/`.typescript`), `specs.md` (+ `.csharp`/`.typescript`), `frontend-testing.md`, `typescript.md`, `react.md`, `components.md`, `dialogs.md`, `frontend-quality.md`, `concepts.md`, `mongodb.md` (when MongoDB is used), `efcore.md`/`efcore.specs.md`.
|
|
29
29
|
|
|
30
30
|
---
|
|
31
31
|
|
|
@@ -42,7 +42,7 @@ Your responsibility is to identify performance problems in changed code before t
|
|
|
42
42
|
### MongoDB / Read Models
|
|
43
43
|
|
|
44
44
|
- [ ] Queries filter on indexed fields — no full-collection scans
|
|
45
|
-
- [ ] Paged queries use
|
|
45
|
+
- [ ] Paged queries return a shape Arc pages (`IQueryable<T>`, or MongoDB `Observe`) and use `useWithPaging()` — no hand-written `.Skip()`/`.Take()`/`OrderBy` for the client's page or sort, never load all rows
|
|
46
46
|
- [ ] Read-model `record` types do not embed large nested collections that are never fully iterated
|
|
47
47
|
- [ ] No N+1 pattern: single query returns all needed data rather than one query per row
|
|
48
48
|
|
|
@@ -16,7 +16,7 @@ Everything tagged `profile: universal` holds in framework repos exactly as in ap
|
|
|
16
16
|
|
|
17
17
|
## What does NOT apply
|
|
18
18
|
|
|
19
|
-
Skip these `profile: application` rules entirely when in a framework repo: `vertical-slices.md`, `reactors.md`, `react.md`, `components.md`, `dialogs.md`, `frontend-quality.md`, `frontend-testing.md`, `storybook.md`, `efcore.md`, `efcore.specs.md`. Do not create vertical-slice folders, `[Command]`/`Handle()` records, read models, or MVVM app components in framework source.
|
|
19
|
+
Skip these `profile: application` rules entirely when in a framework repo: `vertical-slices.md`, `reactors.md`, `react.md`, `components.md`, `dialogs.md`, `frontend-quality.md`, `frontend-testing.md`, `storybook.md`, `efcore.md`, `efcore.specs.md`, `mongodb.md`. Do not create vertical-slice folders, `[Command]`/`Handle()` records, read models, or MVVM app components in framework source.
|
|
20
20
|
|
|
21
21
|
## The repos and their shape
|
|
22
22
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
applyTo: "**/*.cs"
|
|
3
|
+
paths:
|
|
4
|
+
- "**/*.cs"
|
|
5
|
+
profile: application
|
|
6
|
+
---
|
|
7
|
+
<!-- cratis-ai: generated Copilot instructions. Do not edit; rules/ is the source. -->
|
|
8
|
+
|
|
9
|
+
# MongoDB Instructions
|
|
10
|
+
|
|
11
|
+
> **Applies only to code that reads or writes MongoDB** — read-model queries, repositories, and
|
|
12
|
+
> projection or reducer sinks that use `IMongoCollection<T>`. Every line is tagged **[contract]**
|
|
13
|
+
> (binding) or **[convention]** (the house default) per the Three Levels of Authority in
|
|
14
|
+
> [`general.md`](../../../rules/general.md).
|
|
15
|
+
|
|
16
|
+
## Use the native driver API, not LINQ
|
|
17
|
+
|
|
18
|
+
- **[convention] Query MongoDB through the driver's native API, not LINQ.** Build filters, sorts,
|
|
19
|
+
projections and updates with `Builders<T>` (`Filter`, `Sort`, `Projection`, `Update`), read with
|
|
20
|
+
`Find` or an `Aggregate` pipeline, and use `FilterDefinition<T>` / `PipelineDefinition<,>` for
|
|
21
|
+
anything composed. Do not call `collection.AsQueryable()`.
|
|
22
|
+
- **[convention] Why.** The native API exposes everything the server can do (`$lookup`, `$facet`,
|
|
23
|
+
`$group`, `$unionWith`, window and text operators), and it leaves no expression-tree translation
|
|
24
|
+
step between your code and the server. LINQ is translated by the driver at execution time: an
|
|
25
|
+
expression it cannot translate compiles fine and fails at runtime with `Expression not supported`.
|
|
26
|
+
Concept-typed members, casts, and operators the translator does not know are where that shows up.
|
|
27
|
+
- **[convention] A member selector inside a `Builders<T>` call is acceptable** —
|
|
28
|
+
`Builders<T>.Filter.Eq(item => item.Status, status)` names a field, it is not a LINQ pipeline.
|
|
29
|
+
Anything with `Select`, `GroupBy`, `Join`, `Cast`, or a method call on a member goes to an
|
|
30
|
+
aggregation pipeline or a filter definition instead. To avoid translation altogether, address the
|
|
31
|
+
field by its BSON element name: `new StringFieldDefinition<T, TValue>("_id")` is what Arc's own
|
|
32
|
+
`FindById` uses.
|
|
33
|
+
- **[convention] Filter in the database, not after it.** Put the predicate in the `Find` filter or a
|
|
34
|
+
`$match` stage. Never `ToList()` and then filter, sort or slice in memory.
|
|
35
|
+
|
|
36
|
+
```csharp
|
|
37
|
+
// One document
|
|
38
|
+
await collection.Find(Builders<Project>.Filter.Eq(project => project.Id, id)).FirstOrDefaultAsync();
|
|
39
|
+
|
|
40
|
+
// A filtered list, composed from definitions
|
|
41
|
+
var filter = Builders<Project>.Filter.And(
|
|
42
|
+
Builders<Project>.Filter.Eq(project => project.IsArchived, false),
|
|
43
|
+
Builders<Project>.Filter.Eq(project => project.Owner, owner));
|
|
44
|
+
await collection.Find(filter).ToListAsync();
|
|
45
|
+
|
|
46
|
+
// Anything beyond filter/sort/project is an aggregation pipeline; field names are BSON element names
|
|
47
|
+
var perOwner = await collection.Aggregate()
|
|
48
|
+
.Match(filter)
|
|
49
|
+
.Group(new BsonDocument { { "_id", "$owner" }, { "count", new BsonDocument("$sum", 1) } })
|
|
50
|
+
.ToListAsync();
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Paging and sorting belong to Arc
|
|
54
|
+
|
|
55
|
+
- **[convention] A query method does not page or sort for the client.** Never write `Skip`, `Take`,
|
|
56
|
+
`Limit` or `OrderBy` to implement `page`, `pageSize` or `sortby`; Arc reads those from the request
|
|
57
|
+
and applies them for the shapes it supports. For which MongoDB shapes that is, how the sort field is
|
|
58
|
+
resolved, and what a query author must not do, use the **cratis-arc-query-paging** skill.
|
|
59
|
+
- **[convention] A list that can grow is returned through a shape Arc pages.** For MongoDB that is
|
|
60
|
+
`ISubject<IEnumerable<T>>` from the `Observe` helpers, which take a `FilterDefinition<T>`. A
|
|
61
|
+
`Find(...).ToListAsync()` result is not paged, so it is for bounded sets only.
|
|
62
|
+
|
|
63
|
+
## Related
|
|
64
|
+
|
|
65
|
+
- Read-model queries and the `Observe` family: the **cratis-chronicle-read-model** skill.
|
|
66
|
+
- Paging and sorting: the **cratis-arc-query-paging** skill.
|
|
67
|
+
- Query cost and scalability review: the **cratis-performance-review** skill.
|
|
@@ -98,7 +98,7 @@ A typed proxy class is generated per query. Pick the hook:
|
|
|
98
98
|
| `<Query>.use(args?, sorting?)` | standard — returns `[result, perform, setSorting]`, re-renders on change |
|
|
99
99
|
| `<Query>.useSuspense(...)` | suspense-aware — throws while loading; wrap in `<QueryBoundary>` |
|
|
100
100
|
| `<Query>.when(condition).use(args)` | conditional — only fires when `condition` is true (don't wrap a hook in `if`) |
|
|
101
|
-
| `<Query>.useWithPaging(pageSize, args?, sorting?)` | server-side paging (backend returns `IQueryable<T>`) |
|
|
101
|
+
| `<Query>.useWithPaging(pageSize, args?, sorting?)` | server-side paging (backend returns `IQueryable<T>` or, for MongoDB, `ISubject<IEnumerable<T>>` from `Observe`) |
|
|
102
102
|
| `<Query>.useSuspenseWithPaging(pageSize, ...)` | suspense + paging |
|
|
103
103
|
| `<ObservableQuery>.use(...)` | live observable (`ISubject<T>` backend) pushed over SSE/WebSocket |
|
|
104
104
|
| `<ObservableQuery>.useChangeStream(args?, getKey?, ...)` | item-level add/replace/remove deltas |
|
|
@@ -114,7 +114,7 @@ A typed proxy class is generated per query. Pick the hook:
|
|
|
114
114
|
| `<ObservableQuery>.use(...)` | `[result, setSorting]` for an enumerable query; `[result]` for a single-instance one — **no `perform`** |
|
|
115
115
|
| `<ObservableQuery>.useChangeStream(args?, getKey?, sorting?)` | `ChangeSet<T> { added, replaced, removed }` |
|
|
116
116
|
|
|
117
|
-
`result.paging` = `{ page, size, totalItems, totalPages }` (zero-based `page`). Read models returning `IQueryable<TReadModel
|
|
117
|
+
`result.paging` = `{ page, size, totalItems, totalPages }` (zero-based `page`). Read models returning `IQueryable<TReadModel>`, or `ISubject<IEnumerable<TReadModel>>` from MongoDB `Observe`, get automatic server-side paging/sorting — use one whenever a list can grow, and never page or sort in the query itself.
|
|
118
118
|
|
|
119
119
|
- **`useChangeStream` `getKey`:** without `getKey`, removed items can't be identified — everything shows up as `added`. `getKey` enables `replaced` detection. Change streams exist only on observable queries returning `IEnumerable<T>`.
|
|
120
120
|
- **Conditional:** `<Query>.when(!!id).use({ id: id ?? '' })` — when `condition` is false no request/subscription is made and the hook returns `QueryResultWithState.empty()` (`hasData: false`, empty `data`); guard with `result.hasData`. `.when()` works with paging, suspense, and observable variants too.
|
|
@@ -263,7 +263,8 @@ observations emit `default` on absence/deletion/filter exit: guard `hasData` and
|
|
|
263
263
|
|
|
264
264
|
Stay within analyzer-supported shapes: the read model, `T?`, a collection/array/`IQueryable<T>` of it, `Task<...>` of those, `IAsyncEnumerable<T>`, or `ISubject<...>` of read model/collection.
|
|
265
265
|
|
|
266
|
-
- **`IQueryable<TReadModel>`** →
|
|
266
|
+
- **`IQueryable<TReadModel>`** → Arc pages and sorts it through LINQ. Right for a LINQ-native provider such as Entity Framework Core.
|
|
267
|
+
- **`ISubject<IEnumerable<TReadModel>>` from `collection.Observe(...)`** → the MongoDB way to a paged, sorted list, built on a native `Find` with no LINQ (frontend `useWithPaging` — see the **cratis-arc-query-paging** skill). **[contract]** A query never writes `Skip`/`Take`/`OrderBy` for the client's paging or sorting; Arc applies them. Use one of these two shapes whenever a list can grow.
|
|
267
268
|
- **`ISubject<...>`** → live/observable queries. Return it directly — **never** `Task<ISubject<...>>`.
|
|
268
269
|
- **[contract] Custom paths use `[Path("...")]`** (`PathAttribute`), not ASP.NET `[Route]`. Reserve `[Route]` for controller endpoints (which this convention avoids).
|
|
269
270
|
|
|
@@ -272,7 +273,7 @@ Stay within analyzer-supported shapes: the read model, `T?`, a collection/array/
|
|
|
272
273
|
[FromEvent<AuthorRegistered>]
|
|
273
274
|
public record Author(AuthorId Id, AuthorName Name)
|
|
274
275
|
{
|
|
275
|
-
public static
|
|
276
|
+
public static ISubject<IEnumerable<Author>> AllAuthors(IMongoCollection<Author> collection) => collection.Observe();
|
|
276
277
|
public static Task<Author?> AuthorById(IReadModels readModels, AuthorId id) =>
|
|
277
278
|
readModels.GetInstanceById<Author>((EventSourceId)id);
|
|
278
279
|
}
|
|
@@ -15,7 +15,7 @@ Everything tagged `profile: universal` holds in framework repos exactly as in ap
|
|
|
15
15
|
|
|
16
16
|
## What does NOT apply
|
|
17
17
|
|
|
18
|
-
Skip these `profile: application` rules entirely when in a framework repo: `vertical-slices.md`, `reactors.md`, `react.md`, `components.md`, `dialogs.md`, `frontend-quality.md`, `frontend-testing.md`, `storybook.md`, `efcore.md`, `efcore.specs.md`. Do not create vertical-slice folders, `[Command]`/`Handle()` records, read models, or MVVM app components in framework source.
|
|
18
|
+
Skip these `profile: application` rules entirely when in a framework repo: `vertical-slices.md`, `reactors.md`, `react.md`, `components.md`, `dialogs.md`, `frontend-quality.md`, `frontend-testing.md`, `storybook.md`, `efcore.md`, `efcore.specs.md`, `mongodb.md`. Do not create vertical-slice folders, `[Command]`/`Handle()` records, read models, or MVVM app components in framework source.
|
|
19
19
|
|
|
20
20
|
## The repos and their shape
|
|
21
21
|
|
|
@@ -143,6 +143,7 @@ A claim is only as good as the signal behind it — a build result, a test run,
|
|
|
143
143
|
| Diagnosing a misbehaving slice | read model stale or startup crash: **cratis-chronicle-projection** (*Startup-crash traps*); reactor paused or quarantined: **cratis-chronicle-reactor** (*Failure behavior*); proxy missing: **cratis-arc-command** (*Generate the TypeScript proxy*) |
|
|
144
144
|
| Inspecting or operating a **running** Chronicle store (failed partitions, replays, browsing events) with the `cratis` CLI | the **cratis-chronicle-cli-operations** and **cratis-cli-terminal-workbench** skills |
|
|
145
145
|
| EF Core read models / migrations | `efcore.md`, `efcore.specs.md` |
|
|
146
|
+
| MongoDB queries — native driver API over LINQ; paging and sorting belong to Arc | `mongodb.md` |
|
|
146
147
|
| Before writing a commit message, committing, pushing or opening/editing a PR: read both | `git-commits.md`, `pull-requests.md` |
|
|
147
148
|
| Reading, citing and superseding a decision record | the **cratis-engineering-decision-record** skill |
|
|
148
149
|
| Whether you are allowed to do the thing you are able to do | `capability-is-not-authority.md` |
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
applyTo: "**/*.cs"
|
|
3
|
+
paths:
|
|
4
|
+
- "**/*.cs"
|
|
5
|
+
profile: application
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# MongoDB Instructions
|
|
9
|
+
|
|
10
|
+
> **Applies only to code that reads or writes MongoDB** — read-model queries, repositories, and
|
|
11
|
+
> projection or reducer sinks that use `IMongoCollection<T>`. Every line is tagged **[contract]**
|
|
12
|
+
> (binding) or **[convention]** (the house default) per the Three Levels of Authority in
|
|
13
|
+
> [`general.md`](./general.md).
|
|
14
|
+
|
|
15
|
+
## Use the native driver API, not LINQ
|
|
16
|
+
|
|
17
|
+
- **[convention] Query MongoDB through the driver's native API, not LINQ.** Build filters, sorts,
|
|
18
|
+
projections and updates with `Builders<T>` (`Filter`, `Sort`, `Projection`, `Update`), read with
|
|
19
|
+
`Find` or an `Aggregate` pipeline, and use `FilterDefinition<T>` / `PipelineDefinition<,>` for
|
|
20
|
+
anything composed. Do not call `collection.AsQueryable()`.
|
|
21
|
+
- **[convention] Why.** The native API exposes everything the server can do (`$lookup`, `$facet`,
|
|
22
|
+
`$group`, `$unionWith`, window and text operators), and it leaves no expression-tree translation
|
|
23
|
+
step between your code and the server. LINQ is translated by the driver at execution time: an
|
|
24
|
+
expression it cannot translate compiles fine and fails at runtime with `Expression not supported`.
|
|
25
|
+
Concept-typed members, casts, and operators the translator does not know are where that shows up.
|
|
26
|
+
- **[convention] A member selector inside a `Builders<T>` call is acceptable** —
|
|
27
|
+
`Builders<T>.Filter.Eq(item => item.Status, status)` names a field, it is not a LINQ pipeline.
|
|
28
|
+
Anything with `Select`, `GroupBy`, `Join`, `Cast`, or a method call on a member goes to an
|
|
29
|
+
aggregation pipeline or a filter definition instead. To avoid translation altogether, address the
|
|
30
|
+
field by its BSON element name: `new StringFieldDefinition<T, TValue>("_id")` is what Arc's own
|
|
31
|
+
`FindById` uses.
|
|
32
|
+
- **[convention] Filter in the database, not after it.** Put the predicate in the `Find` filter or a
|
|
33
|
+
`$match` stage. Never `ToList()` and then filter, sort or slice in memory.
|
|
34
|
+
|
|
35
|
+
```csharp
|
|
36
|
+
// One document
|
|
37
|
+
await collection.Find(Builders<Project>.Filter.Eq(project => project.Id, id)).FirstOrDefaultAsync();
|
|
38
|
+
|
|
39
|
+
// A filtered list, composed from definitions
|
|
40
|
+
var filter = Builders<Project>.Filter.And(
|
|
41
|
+
Builders<Project>.Filter.Eq(project => project.IsArchived, false),
|
|
42
|
+
Builders<Project>.Filter.Eq(project => project.Owner, owner));
|
|
43
|
+
await collection.Find(filter).ToListAsync();
|
|
44
|
+
|
|
45
|
+
// Anything beyond filter/sort/project is an aggregation pipeline; field names are BSON element names
|
|
46
|
+
var perOwner = await collection.Aggregate()
|
|
47
|
+
.Match(filter)
|
|
48
|
+
.Group(new BsonDocument { { "_id", "$owner" }, { "count", new BsonDocument("$sum", 1) } })
|
|
49
|
+
.ToListAsync();
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Paging and sorting belong to Arc
|
|
53
|
+
|
|
54
|
+
- **[convention] A query method does not page or sort for the client.** Never write `Skip`, `Take`,
|
|
55
|
+
`Limit` or `OrderBy` to implement `page`, `pageSize` or `sortby`; Arc reads those from the request
|
|
56
|
+
and applies them for the shapes it supports. For which MongoDB shapes that is, how the sort field is
|
|
57
|
+
resolved, and what a query author must not do, use the **cratis-arc-query-paging** skill.
|
|
58
|
+
- **[convention] A list that can grow is returned through a shape Arc pages.** For MongoDB that is
|
|
59
|
+
`ISubject<IEnumerable<T>>` from the `Observe` helpers, which take a `FilterDefinition<T>`. A
|
|
60
|
+
`Find(...).ToListAsync()` result is not paged, so it is for bounded sets only.
|
|
61
|
+
|
|
62
|
+
## Related
|
|
63
|
+
|
|
64
|
+
- Read-model queries and the `Observe` family: the **cratis-chronicle-read-model** skill.
|
|
65
|
+
- Paging and sorting: the **cratis-arc-query-paging** skill.
|
|
66
|
+
- Query cost and scalability review: the **cratis-performance-review** skill.
|
|
@@ -97,7 +97,7 @@ A typed proxy class is generated per query. Pick the hook:
|
|
|
97
97
|
| `<Query>.use(args?, sorting?)` | standard — returns `[result, perform, setSorting]`, re-renders on change |
|
|
98
98
|
| `<Query>.useSuspense(...)` | suspense-aware — throws while loading; wrap in `<QueryBoundary>` |
|
|
99
99
|
| `<Query>.when(condition).use(args)` | conditional — only fires when `condition` is true (don't wrap a hook in `if`) |
|
|
100
|
-
| `<Query>.useWithPaging(pageSize, args?, sorting?)` | server-side paging (backend returns `IQueryable<T>`) |
|
|
100
|
+
| `<Query>.useWithPaging(pageSize, args?, sorting?)` | server-side paging (backend returns `IQueryable<T>` or, for MongoDB, `ISubject<IEnumerable<T>>` from `Observe`) |
|
|
101
101
|
| `<Query>.useSuspenseWithPaging(pageSize, ...)` | suspense + paging |
|
|
102
102
|
| `<ObservableQuery>.use(...)` | live observable (`ISubject<T>` backend) pushed over SSE/WebSocket |
|
|
103
103
|
| `<ObservableQuery>.useChangeStream(args?, getKey?, ...)` | item-level add/replace/remove deltas |
|
|
@@ -113,7 +113,7 @@ A typed proxy class is generated per query. Pick the hook:
|
|
|
113
113
|
| `<ObservableQuery>.use(...)` | `[result, setSorting]` for an enumerable query; `[result]` for a single-instance one — **no `perform`** |
|
|
114
114
|
| `<ObservableQuery>.useChangeStream(args?, getKey?, sorting?)` | `ChangeSet<T> { added, replaced, removed }` |
|
|
115
115
|
|
|
116
|
-
`result.paging` = `{ page, size, totalItems, totalPages }` (zero-based `page`). Read models returning `IQueryable<TReadModel
|
|
116
|
+
`result.paging` = `{ page, size, totalItems, totalPages }` (zero-based `page`). Read models returning `IQueryable<TReadModel>`, or `ISubject<IEnumerable<TReadModel>>` from MongoDB `Observe`, get automatic server-side paging/sorting — use one whenever a list can grow, and never page or sort in the query itself.
|
|
117
117
|
|
|
118
118
|
- **`useChangeStream` `getKey`:** without `getKey`, removed items can't be identified — everything shows up as `added`. `getKey` enables `replaced` detection. Change streams exist only on observable queries returning `IEnumerable<T>`.
|
|
119
119
|
- **Conditional:** `<Query>.when(!!id).use({ id: id ?? '' })` — when `condition` is false no request/subscription is made and the hook returns `QueryResultWithState.empty()` (`hasData: false`, empty `data`); guard with `result.hasData`. `.when()` works with paging, suspense, and observable variants too.
|
|
@@ -262,7 +262,8 @@ observations emit `default` on absence/deletion/filter exit: guard `hasData` and
|
|
|
262
262
|
|
|
263
263
|
Stay within analyzer-supported shapes: the read model, `T?`, a collection/array/`IQueryable<T>` of it, `Task<...>` of those, `IAsyncEnumerable<T>`, or `ISubject<...>` of read model/collection.
|
|
264
264
|
|
|
265
|
-
- **`IQueryable<TReadModel>`** →
|
|
265
|
+
- **`IQueryable<TReadModel>`** → Arc pages and sorts it through LINQ. Right for a LINQ-native provider such as Entity Framework Core.
|
|
266
|
+
- **`ISubject<IEnumerable<TReadModel>>` from `collection.Observe(...)`** → the MongoDB way to a paged, sorted list, built on a native `Find` with no LINQ (frontend `useWithPaging` — see the **cratis-arc-query-paging** skill). **[contract]** A query never writes `Skip`/`Take`/`OrderBy` for the client's paging or sorting; Arc applies them. Use one of these two shapes whenever a list can grow.
|
|
266
267
|
- **`ISubject<...>`** → live/observable queries. Return it directly — **never** `Task<ISubject<...>>`.
|
|
267
268
|
- **[contract] Custom paths use `[Path("...")]`** (`PathAttribute`), not ASP.NET `[Route]`. Reserve `[Route]` for controller endpoints (which this convention avoids).
|
|
268
269
|
|
|
@@ -271,7 +272,7 @@ Stay within analyzer-supported shapes: the read model, `T?`, a collection/array/
|
|
|
271
272
|
[FromEvent<AuthorRegistered>]
|
|
272
273
|
public record Author(AuthorId Id, AuthorName Name)
|
|
273
274
|
{
|
|
274
|
-
public static
|
|
275
|
+
public static ISubject<IEnumerable<Author>> AllAuthors(IMongoCollection<Author> collection) => collection.Observe();
|
|
275
276
|
public static Task<Author?> AuthorById(IReadModels readModels, AuthorId id) =>
|
|
276
277
|
readModels.GetInstanceById<Author>((EventSourceId)id);
|
|
277
278
|
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cratis-arc-query-paging
|
|
3
|
-
description: Add server-side paging and sorting to a Cratis Arc read-model query — which return shapes Arc pages, the exact query-string keys, sorting pitfalls per storage provider, and the paged frontend hooks. Use when a list query can grow large enough that returning every row is wasteful, or needs server-side sorting
|
|
3
|
+
description: Add server-side paging and sorting to a Cratis Arc read-model query — which return shapes Arc pages, what a query author must not write (Skip, Take, OrderBy), how MongoDB pages and sorts without LINQ, the exact query-string keys, sorting pitfalls per storage provider, and the paged frontend hooks. Use when a list query can grow large enough that returning every row is wasteful, or needs server-side sorting, or a paged query fails with `Expression not supported`. Do not use for general query creation or as a performance review.
|
|
4
4
|
license: MIT
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Page and sort an Arc query
|
|
8
8
|
|
|
9
|
-
Arc applies paging and sorting for you.
|
|
10
|
-
|
|
9
|
+
Arc applies paging and sorting for you. **A query never pages or sorts itself:**
|
|
10
|
+
it does not write `Skip`, `Take`, `Limit` or `OrderBy` to serve `page`, `pageSize`
|
|
11
|
+
or `sortby`, and it does not read the query string. It chooses a **return shape**
|
|
12
|
+
Arc can narrow and, for MongoDB, builds that shape with the native driver API (see
|
|
13
|
+
the MongoDB rule) rather than LINQ.
|
|
11
14
|
|
|
12
15
|
- An accepted `.play` model under the model root covers the behavior, or the
|
|
13
16
|
repository is opted in (per `general.md` "Model first"; where `cratis-screenplay-modeling-lifecycle` is installed, run its `scripts/model-first-status.mjs`): change the model first with
|
|
@@ -21,8 +24,8 @@ the query string — you choose a **return shape** that the framework can narrow
|
|
|
21
24
|
|
|
22
25
|
| Package | Purpose |
|
|
23
26
|
| --- | --- |
|
|
24
|
-
| `Cratis.Arc.Core` | `QueryableQueryRenderer`, `Paging`, `Sorting`, `PagingInfo` |
|
|
25
|
-
| `Cratis.Arc.MongoDB` | `Observe` helpers that page at the source |
|
|
27
|
+
| `Cratis.Arc.Core` | `QueryableQueryRenderer`, `Paging`, `Sorting`, `PagingInfo`, `IQueryContextManager` |
|
|
28
|
+
| `Cratis.Arc.MongoDB` | `Observe` helpers that page and sort at the source with native `Find` |
|
|
26
29
|
| `Cratis.Arc.EntityFrameworkCore` | `DbSet<T>` observe helpers that page at the source |
|
|
27
30
|
| `@cratis/arc.react` | `useWithPaging`, `useSuspenseWithPaging` |
|
|
28
31
|
|
|
@@ -58,38 +61,57 @@ return type.
|
|
|
58
61
|
|
|
59
62
|
| Return | Paged? | How |
|
|
60
63
|
| --- | --- | --- |
|
|
61
|
-
| `T`, `T?`, `IEnumerable<T>`, `List<T>`, `T[]` | No | Nothing narrows the result |
|
|
62
|
-
| `IQueryable<T>` | **Yes** | The query renderer counts, orders, then applies `Skip`/`Take` |
|
|
64
|
+
| `T`, `T?`, `IEnumerable<T>`, `List<T>`, `T[]` | No | Nothing narrows the result. `Find(...).ToListAsync()` lands here |
|
|
65
|
+
| `IQueryable<T>` | **Yes** | The query renderer counts, orders, then applies `Skip`/`Take` through LINQ |
|
|
63
66
|
| `Task<IQueryable<T>>` | **Yes** | Awaited first, then rendered as the queryable it unwraps to |
|
|
64
|
-
| `ISubject<IEnumerable<T>>` | **Yes,
|
|
67
|
+
| `ISubject<IEnumerable<T>>` from `collection.Observe(...)` | **Yes, at the source** | `Observe` reads the ambient query context and applies sort, `Skip` and `Limit` to a native `Find`, and counts with `CountDocumentsAsync` |
|
|
68
|
+
| `ISubject<IEnumerable<T>>` you build yourself | **No** | The helper does the paging, not the renderer; a custom subject does not page automatically |
|
|
65
69
|
| `ISubject<IQueryable<T>>` | **No** | No `Observe` overload returns it, and the renderer matches on the outer type |
|
|
66
70
|
|
|
67
71
|
There is exactly one query renderer, and it is for `IQueryable`. Everything else
|
|
68
72
|
that pages does so inside its storage provider.
|
|
69
73
|
|
|
74
|
+
### MongoDB: page without LINQ
|
|
75
|
+
|
|
76
|
+
The house preference for MongoDB is the native driver API, not `AsQueryable()`.
|
|
77
|
+
The `IQueryable` shape is rendered with LINQ, so MongoDB translates the
|
|
78
|
+
renderer's `Skip`/`Take` and the sort the client asked for, and any expression the
|
|
79
|
+
translator cannot handle fails at request time as HTTP 500 with
|
|
80
|
+
`Expression not supported`. A native `Find` has no translation step, so nothing
|
|
81
|
+
can fail there.
|
|
82
|
+
|
|
83
|
+
For a paged MongoDB list, return what `Observe` returns and pass a
|
|
84
|
+
`FilterDefinition<T>`. Do not add paging or ordering:
|
|
85
|
+
|
|
70
86
|
```csharp
|
|
71
87
|
[ReadModel]
|
|
72
88
|
public record <ReadModel>(<...>)
|
|
73
89
|
{
|
|
74
|
-
|
|
75
|
-
|
|
90
|
+
// Everything; Arc applies the client's sort, page and size.
|
|
91
|
+
public static ISubject<IEnumerable<<ReadModel>>> All(IMongoCollection<<ReadModel>> collection) =>
|
|
92
|
+
collection.Observe();
|
|
76
93
|
|
|
77
|
-
//
|
|
78
|
-
public static
|
|
79
|
-
collection.
|
|
80
|
-
|
|
81
|
-
// A default order so paging is stable when the caller sends no sortby.
|
|
82
|
-
public static IQueryable<<ReadModel>> AllByName(<Collection> collection) =>
|
|
83
|
-
collection.AsQueryable().OrderBy(_ => _.<Name>);
|
|
94
|
+
// Narrow with a filter definition; Arc pages on top of the filtered set.
|
|
95
|
+
public static ISubject<IEnumerable<<ReadModel>>> Active(IMongoCollection<<ReadModel>> collection) =>
|
|
96
|
+
collection.Observe(Builders<<ReadModel>>.Filter.Eq(item => item.<IsArchived>, false));
|
|
84
97
|
}
|
|
85
98
|
```
|
|
86
99
|
|
|
87
|
-
|
|
100
|
+
`Observe` is also what serves a one-shot request: a plain `GET` or `QUERY` on an
|
|
101
|
+
observable query returns the current value, and the observable-query HTTP skill
|
|
102
|
+
covers the wait and streaming forms. The frontend uses the observable query's
|
|
103
|
+
`useWithPaging`, which has no `perform`.
|
|
88
104
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
105
|
+
`IQueryable<T>` stays the right shape for a LINQ-native provider such as Entity
|
|
106
|
+
Framework Core. If a MongoDB query still returns `IQueryable<T>`, treat it as a
|
|
107
|
+
known translation risk and move it to `Observe`.
|
|
108
|
+
|
|
109
|
+
⚠️ **`Expression not supported ... Skip(0).Take(30).Cast()` on a paged MongoDB
|
|
110
|
+
query.** Arc up to 22.50.5 appended LINQ `Cast<object>()` to the paged queryable
|
|
111
|
+
while running read-model interceptors, and MongoDB cannot translate it, so every
|
|
112
|
+
paged `IQueryable` request on MongoDB returned HTTP 500 whatever the query
|
|
113
|
+
looked like. Arc 22.50.6 enumerates before casting. Upgrade first; the guidance
|
|
114
|
+
above is what keeps the next unsupported expression from reaching the translator.
|
|
93
115
|
|
|
94
116
|
`ARC0001` is an **error** when a query method's return type is none of the
|
|
95
117
|
allowed shapes for its read model, and `ARC0014` is an error when a
|
|
@@ -106,7 +128,10 @@ and orders unsorted pages on `_id`. See the read-model skill's [versioned query-
|
|
|
106
128
|
|
|
107
129
|
- Do not `.ToList()` before returning an `IQueryable<T>` — that defeats
|
|
108
130
|
`Skip`/`Take`.
|
|
109
|
-
- Do not hard-code `Take(n)`; it fights `pageSize`.
|
|
131
|
+
- Do not hard-code `Take(n)`, `Skip(n)` or `Limit(n)`; it fights `pageSize`.
|
|
132
|
+
- Do not apply `OrderBy`/`ThenBy`/`Sort` to serve client-controlled sorting. On
|
|
133
|
+
`IQueryable<T>` the renderer applies the client's `OrderBy` after your query;
|
|
134
|
+
on `Observe` the helper applies the sort.
|
|
110
135
|
- ⚠️ `(await ...).AsQueryable()` **pages correctly and costs everything.**
|
|
111
136
|
LINQ-to-objects honours `Skip`/`Take`, so the rows are right and the whole set
|
|
112
137
|
was read to produce them. Nothing in the build or the specs will tell you.
|
|
@@ -117,24 +142,36 @@ a second round trip to the store per query.
|
|
|
117
142
|
|
|
118
143
|
## Sorting behaves differently per shape
|
|
119
144
|
|
|
120
|
-
⚠️ This is the sharpest edge here.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
`
|
|
145
|
+
⚠️ This is the sharpest edge here. The client's `sortby`/`sortDirection` is the
|
|
146
|
+
only sort Arc applies; the query does not supply one.
|
|
147
|
+
|
|
148
|
+
- **MongoDB `Observe`.** The sort field is looked up in the document's BSON class
|
|
149
|
+
map by member name and applied by its **BSON element name**, so concept-typed
|
|
150
|
+
members sort by the stored value and renamed elements work. An unknown field
|
|
151
|
+
degrades to unsorted rather than failing. Ties break on `_id` ascending, and a
|
|
152
|
+
paged request with no sort is ordered by `_id`, so page boundaries are stable
|
|
153
|
+
without a default order.
|
|
154
|
+
- **`IQueryable<T>`.** The sort field is resolved with
|
|
155
|
+
`ElementType.GetProperty(field)` and dereferenced without a guard. **An unknown
|
|
156
|
+
`sortby` is a server error (HTTP 500), not an ignored parameter.** Constrain the
|
|
157
|
+
field if callers can choose it. The renderer applies the client's sort with
|
|
158
|
+
`Queryable.OrderBy`, which starts a new primary ordering rather than a
|
|
159
|
+
`ThenBy`, so an order you wrote is not combined with it. The Entity
|
|
160
|
+
Framework Core observe path degrades like MongoDB.
|
|
161
|
+
- **No server-side default order.** No `Observe` overload takes a sort, and a
|
|
162
|
+
paged request without `sortby` is ordered by `_id`. Do not add an `OrderBy` to
|
|
163
|
+
fake one. If a screen needs a meaningful initial order, pass it from the
|
|
164
|
+
frontend as the initial `sorting` argument to `useWithPaging(pageSize, args,
|
|
165
|
+
sorting)`; otherwise the order is `_id`.
|
|
166
|
+
|
|
167
|
+
`sortby` is PascalCased on the server, so the client sends the camelCase
|
|
168
|
+
property name the generated proxy uses and the server resolves the CLR property.
|
|
169
|
+
Generated proxies provide field-named sort helpers.
|
|
170
|
+
|
|
171
|
+
For composed MongoDB/EF observations, pass `ignoreQueryContext: true` to
|
|
172
|
+
auxiliary sources so only the primary consumes client paging/sorting and writes
|
|
173
|
+
totals. Single-instance observations emit `default` when absent, deleted or
|
|
174
|
+
outside the filter; guard `hasData`/`isReady` in React.
|
|
138
175
|
Source: [MongoDB observations](https://github.com/Cratis/Arc/blob/main/Documentation/backend/csharp/mongodb/observing-collections.md).
|
|
139
176
|
|
|
140
177
|
## Consume it from the frontend
|
|
@@ -163,8 +200,8 @@ result is not paged.
|
|
|
163
200
|
|
|
164
201
|
## Specify the data contract, not the paging
|
|
165
202
|
|
|
166
|
-
Paging
|
|
167
|
-
the query selects
|
|
203
|
+
Paging and sorting are the framework's responsibility. A specification should pin
|
|
204
|
+
which rows the query selects, not the page window or the client's order.
|
|
168
205
|
|
|
169
206
|
⚠️ A query method that takes a storage collection type cannot be reached from a
|
|
170
207
|
read-model scenario: the scenario materializes read models in memory and has no
|
|
@@ -174,11 +211,13 @@ pinning, keep it in a method a specification can supply arguments to.
|
|
|
174
211
|
|
|
175
212
|
## Verify
|
|
176
213
|
|
|
177
|
-
-
|
|
178
|
-
|
|
179
|
-
`ISubject<IQueryable<T>>`.
|
|
214
|
+
- A MongoDB list returns `collection.Observe(<FilterDefinition>)`, not
|
|
215
|
+
`AsQueryable()`; a LINQ-native provider returns `IQueryable<T>` /
|
|
216
|
+
`Task<IQueryable<T>>`. Never `ISubject<IQueryable<T>>`.
|
|
217
|
+
- The query contains no `Skip`, `Take`, `Limit`, `OrderBy` or `Sort` serving the
|
|
218
|
+
client's page or sort.
|
|
180
219
|
- Nothing materializes the set before returning it.
|
|
181
|
-
-
|
|
220
|
+
- Any default order a screen needs is the frontend's initial `sorting`, not a server `OrderBy`.
|
|
182
221
|
- A caller-supplied `sortby` cannot reach an `IQueryable<T>` query as an unknown
|
|
183
222
|
field.
|
|
184
223
|
- The frontend destructures the tuple shape that matches the query kind.
|
|
@@ -95,8 +95,9 @@ class PagingInfo { page: number; size: number; totalItems: number; totalPages: n
|
|
|
95
95
|
```
|
|
96
96
|
|
|
97
97
|
`page` is zero-based. `totalItems` is the total count. A read model returning a
|
|
98
|
-
queryable gets server-side paging and sorting
|
|
99
|
-
|
|
98
|
+
queryable, or a MongoDB `Observe` subject, gets server-side paging and sorting
|
|
99
|
+
for free — use one whenever a list can grow, and never page or sort in the
|
|
100
|
+
query. Over the wire the parameters are `page`, `pageSize`, `sortBy`, and
|
|
100
101
|
`sortDirection`.
|
|
101
102
|
|
|
102
103
|
### Change streams
|
|
@@ -76,9 +76,20 @@ public static async Task<IEnumerable<<ReadModelName>>> All<PluralName>(
|
|
|
76
76
|
public static async Task<<ReadModelName>?> <SingularName>ById(
|
|
77
77
|
<IdType> id,
|
|
78
78
|
IMongoCollection<<ReadModelName>> collection) =>
|
|
79
|
-
await collection.Find(model => model.Id
|
|
79
|
+
await collection.Find(Builders<<ReadModelName>>.Filter.Eq(model => model.Id, id)).FirstOrDefaultAsync();
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
+
Query MongoDB with the native driver API (`Find`, `Builders<T>` filter, sort and
|
|
83
|
+
projection definitions, `Aggregate` pipelines), not `collection.AsQueryable()`.
|
|
84
|
+
LINQ is translated at execution time and an expression the driver cannot
|
|
85
|
+
translate fails the request with `Expression not supported`; the native API has
|
|
86
|
+
no such step. See the MongoDB rule.
|
|
87
|
+
|
|
88
|
+
A `Find(...).ToListAsync()` result is **not** paged. Use it for a bounded set. A
|
|
89
|
+
list that can grow is returned with `collection.Observe(<FilterDefinition>)`,
|
|
90
|
+
which Arc pages and sorts for the client without the query writing `Skip`,
|
|
91
|
+
`Take` or `OrderBy`; see the **cratis-arc-query-paging** skill.
|
|
92
|
+
|
|
82
93
|
Name the method for what it returns — the name is the query's identity on the
|
|
83
94
|
generated client surface.
|
|
84
95
|
|
|
@@ -136,8 +136,9 @@ degradations a reviewer can see without measuring:
|
|
|
136
136
|
- A new projection that could not replay all earlier events; events carrying
|
|
137
137
|
large blobs.
|
|
138
138
|
- A query that does not filter on an indexed field; a growable list that does
|
|
139
|
-
not return `IQueryable<T
|
|
140
|
-
|
|
139
|
+
not return a shape Arc pages (`IQueryable<T>`, or MongoDB `Observe`); a query
|
|
140
|
+
that pages or sorts itself or uses MongoDB `AsQueryable()`; hydrating a
|
|
141
|
+
collection only to count it.
|
|
141
142
|
- An N+1 pattern; a response payload with fields no client reads.
|
|
142
143
|
- React: a growable list rendering every row; an inline object or array literal
|
|
143
144
|
passed as a prop, changing identity every render; wrong `useEffect`
|
|
@@ -63,8 +63,11 @@ behavior this file does not already state.
|
|
|
63
63
|
|
|
64
64
|
- Queries filter on indexed fields. An unintentional full-collection scan is the
|
|
65
65
|
most common finding here.
|
|
66
|
-
- **A list that can grow returns `IQueryable<T
|
|
67
|
-
|
|
66
|
+
- **A list that can grow returns a shape Arc pages** — `IQueryable<T>` for a
|
|
67
|
+
LINQ-native provider, `collection.Observe(...)` for MongoDB — so Arc applies
|
|
68
|
+
server-side paging and sorting. A query that writes its own `Skip`/`Take` or
|
|
69
|
+
`OrderBy` for the client's page or sort is a finding, as is MongoDB
|
|
70
|
+
`AsQueryable()` (translation failures at request time). Materializing and then slicing in memory reads the whole
|
|
68
71
|
collection every request.
|
|
69
72
|
- No N+1: one query returns what the caller needs.
|
|
70
73
|
- A count is a count. Hydrating the collection to measure its length is the
|