@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.
@@ -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 `.Skip()` + `.Take()` (or `useWithPaging()`) — never load all rows
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>` get automatic server-side paging/sorting — use it whenever a list can grow.
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>`** → automatic server-side paging/sorting (frontend `useWithPaging` — see the **cratis-arc-query-paging** skill). Use it whenever a list can grow.
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 IQueryable<Author> AllAuthors(IMongoCollection<Author> collection) => collection.AsQueryable();
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>` get automatic server-side paging/sorting — use it whenever a list can grow.
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>`** → automatic server-side paging/sorting (frontend `useWithPaging` — see the **cratis-arc-query-paging** skill). Use it whenever a list can grow.
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 IQueryable<Author> AllAuthors(IMongoCollection<Author> collection) => collection.AsQueryable();
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. Do not use for general query creation or as a performance review.
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. You never write `Skip`/`Take` or read
10
- the query string — you choose a **return shape** that the framework can narrow.
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, with a paging-aware storage helper** | Not by the renderer — the helper reads the ambient query context; a custom subject does not page automatically |
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
- public static IQueryable<<ReadModel>> All(<Collection> collection) =>
75
- collection.AsQueryable();
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
- // Filter before returning; Arc pages on top of the filtered queryable.
78
- public static IQueryable<<ReadModel>> Active(<Collection> collection) =>
79
- collection.AsQueryable().Where(_ => !_.<IsArchived>);
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
- For a live paged list, return what `Observe` returns:
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
- ```csharp
90
- public static ISubject<IEnumerable<<ReadModel>>> AllLive(<Collection> collection) =>
91
- collection.Observe(item => !item.<IsArchived>);
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
- - For `IQueryable<T>`, the sort field is resolved with `ElementType.GetProperty(field)`
123
- and the result is dereferenced without a guard. **An unknown `sortby` is a
124
- server error (HTTP 500), not an ignored parameter.** Validate or constrain the
125
- field before it reaches the query if callers can choose it.
126
- - The MongoDB and Entity Framework Core observe paths deliberately **degrade**:
127
- an unknown sort field leaves the result unsorted rather than failing.
128
-
129
- Because `sortby` is PascalCased, the client sends the camelCase property name
130
- the generated proxy uses and the server resolves the CLR property.
131
-
132
- Give any list with a natural order a default `OrderBy`; do not generalize the
133
- MongoDB observation tie-break to every `IQueryable` provider. Generated proxies
134
- provide field-named sort helpers. For composed MongoDB/EF observations, pass
135
- `ignoreQueryContext: true` to auxiliary sources so only the primary consumes
136
- client paging/sorting and writes totals. Single-instance observations emit
137
- `default` when absent/deleted/outside the filter; guard `hasData`/`isReady` in React.
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 is the framework's responsibility. A specification should pin which rows
167
- the query selects and in what order.
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
- - The query returns `IQueryable<T>` / `Task<IQueryable<T>>` for one-shot paging,
178
- or `ISubject<IEnumerable<T>>` for a live paged list — never
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
- - A meaningful default order exists wherever the data has one.
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 for free — use it whenever a list
99
- can grow. Over the wire the parameters are `page`, `pageSize`, `sortBy`, and
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 == id).FirstOrDefaultAsync();
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>` for server-side paging; hydrating a collection only
140
- to count it.
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>`**, so Arc applies server-side
67
- paging and sorting. Materializing and then slicing in memory reads the whole
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/pi",
3
- "version": "7.58.0",
3
+ "version": "7.60.0",
4
4
  "description": "Configuration-aware Cratis AI integration for Pi",
5
5
  "type": "module",
6
6
  "files": [