Skip to content

Commit b64980a

Browse files
os-zhuangclaude
andauthored
feat(data): host-configured default and maximum page size for list, report and analytics reads, and a per-query row budget, refused with a code naming the limit (#29796)
<!-- claude-projects-attribution: {"github_login":"os-zhuang"} --> _Requested by **Sam** · [project thread](https://claude.ai/code/project/chan_01GsBogozeZG5sdpAARrN7HP?thread=cmsg_01GsBogozeZG5sdpAARrN7HP1GdLsoH7z9V8sNW6YoLEMF)_ Part of #29783 This PR is the whole of that card. It says `Part of` rather than a closing keyword because the card's claim names no `claude/issue-*` branch and this PR's head branch cannot take that shape, so `check:closing-target-claim` could never pass with a closing keyword. The seat closes the card when this lands. ## What changes A deployment can now bound how many rows one data API read returns and walks, through three host variables. Each is a positive integer; all three unset is today's behaviour exactly. | Variable | Meaning | Unset | |:--|:--|:--| | `OS_DATA_DEFAULT_PAGE_SIZE` | the page a read gets when it names no `limit` | the maximum when one is set, else no default | | `OS_DATA_MAX_PAGE_SIZE` | the largest `limit` a read may name | no maximum | | `OS_DATA_QUERY_ROW_BUDGET` | the most rows one query may walk: `offset` plus the rows it returns | no budget | **The documented rule for a request over a limit is refuse, never clamp.** A clamp answers a different page from the one asked for: a caller paging by its own `limit` would skip the rows in between, and an analytics answer has no `hasMore` to say a chart was cut. So: - a `limit` over the maximum → `400 MAX_RECORDS_EXCEEDED`, the existing standard code for a caller-side limit (the request asked for more rows than a request may carry), naming the spelling the caller wrote (`$top` / `top` / `limit`) and the maximum; - `offset + limit` over the budget → `400 QUERY_ROW_BUDGET_EXCEEDED`, naming the budget. This is a new code because the budget bounds how deep a query walks, not how big a page is: a page well within the maximum is refused at a deep enough offset. Both are refused before the driver is asked (a budget probe, below, runs first and refuses on its answer). Each refusal writes one `warn` line: `[data] read refused: code=… surface=list|analytics object=… rows=… limit=…`. The line carries the object and counts, never a row value. **A read that names no limit:** - **List reads** (`GET /data/:object`, `POST /data/:object/query`, grouped or flat, through `findData`) run with the default page size as their `limit`. The answer already says when more rows exist (`hasMore`, `total`), so this is an ordinary first page. - **Analytics reads** (`AnalyticsService.query()` behind `/analytics/query`, and `queryDataset()`, the dataset door reports and dashboards read through) run with one row more than the default page. An answer with more rows than the default is refused with `MAX_RECORDS_EXCEEDED`, telling the caller to name a `limit` and page with `offset`. The analytics doors judge the caller's own window; the dataset executor's internal passes (totals, measure-scoped queries) are not judged separately. - **A budget with no page size** probes one row more than the budget leaves after the `offset`. An answer over the budget is refused, and one that fits is served whole (the probe limit is taken back off, so `hasMore` / `total` read as an unpaged answer). **Where it lives:** - `@objectstack/types` `query-read-limits.ts` holds the rule: `resolveQueryReadLimits` (strict: a value that is not a positive integer, or a default above the maximum, throws naming the variable), `planQueryReadWindow`, `assertQueryReadRows`, and the abstract `QueryReadLimitError` (`limit`) with one subclass per code, `QueryPageSizeExceededError` (`MAX_RECORDS_EXCEEDED`) and `QueryRowBudgetExceededError` (`QUERY_ROW_BUDGET_EXCEEDED`), each declaring its own `code` and `status 400` so `check:error-status-conformance` pins the catalog's status. `MAX_RECORDS_EXCEEDED` gains its first producer, so it leaves `scripts/error-status-unpinned-baseline.json` (the shrink-only direction). - `metadata-protocol` `findData` plans every list read after the shape gates and before the engine. - `service-analytics` `query()` and `queryDataset()` plan the caller's window at the public doors. - Both constructors resolve the limits once, so a misconfigured variable fails the boot instead of 500-ing every read. - `QUERY_ROW_BUDGET_EXCEEDED` is registered in the error-code ledger under `@objectstack/types`, with a line on why it is not `MAX_RECORDS_EXCEEDED`; the references pages are regenerated; `content/docs/api/data-api.mdx` documents the three variables and the refusal rule; `content/docs/api/error-catalog.mdx` gains a `QUERY_ROW_BUDGET_EXCEEDED` entry, names the page-size cause under `MAX_RECORDS_EXCEEDED`, and counts 57 wire codes. **What the budget does not count:** rows a database scans inside an aggregate or behind an unindexed filter. The data API cannot see those, so the budget counts the rows a query walks through its window. The expand sub-reads, the export route and internal engine reads (backfills, sweeps) are not data API list reads and are untouched. ## Why The maintainer's 2026-11-03 04:26Z instruction on the cloud plan decision, verbatim: 「同意:在代码里补两个保护再谈放宽:查询扫描行预算和强制分页;每周看板加「每个 Team 组织的数据库大小与行读量」。第 3 个月闸门前有这两组数,再决定存储包价格和数据边界的数字。」 The hosted data plane runs this data API, and on `main` there was no general page-size cap in it or in the SQL / libSQL drivers (re-checked at `946a173f`: `driver-odata`'s `pageSize` is only for its own upstream, and MCP's `maxQueryLimit` only for its own tool). Cloud sets the numbers in its own card (cloud#3510) once a pin carries this. ## Verification - `packages/types/src/query-read-limits.test.ts` (18): configuration (unset, all three, strict refusal of `0` / `-5` / `1.5` / `abc` / `10k` / `1e3`, default above maximum), no limits leave every read untouched, refusal shape (status, code, `limit`, message, the one log line with no row value), the default page, the analytics probe, the budget (including the default page counting against it, and the budget-only probe), and both codes resolving verbatim at 400 through `resolveThrownHttpError`. - `packages/rest/src/data-query-read-limits.test.ts` (6, real `SqlDriver` over SQLite, both list doors): defaults unchanged with nothing set (every row, a 100,000 limit, a grouped read), the default page with `hasMore: true` / `total: 5`, a limit within the maximum, `MAX_RECORDS_EXCEEDED` on `$top`, `top`, `limit` and a grouped read with the driver never called, `QUERY_ROW_BUDGET_EXCEEDED` with the driver never called, and a budget-only read refused when it overruns and served whole (`hasMore: false`, `total: 5`) when it fits. - `packages/services/service-analytics/src/__tests__/query-read-limits.test.ts` (8): both analytics doors unchanged with nothing set (no `LIMIT`, 500 rows served), the probe reaching the query (`limit: 4`) and the SQL (`LIMIT 4`), an answer past the default refused with the log line, a caller's own limit run as asked, over-maximum and over-budget refused with nothing run, and the budget-only probe. - `data-page-window-door.test.ts` (the existing window pins) still passes. Full local suites: `types` 860 tests, `metadata-protocol` 310 files, `service-analytics` 4,958 tests, `rest` 7,139 tests, all passing. - `pnpm typecheck` for `types`, `metadata-protocol`, `service-analytics` and `rest` (with its test layer); eslint on every touched file; `node scripts/check-error-status-conformance.mjs` green; spec `error-code-ledger.test.ts` green; `pnpm --filter @objectstack/spec check:generated` named `content/docs/references/**` stale, regenerated with `gen:docs`; `check-doc-anchors` resolves the new catalog links; `check-error-status-conformance` (and its self-test) green. - `node scripts/pm/dispatch-gates.mjs --tier`: no path-derived tier mandate. Its Clause ② hint fires on `packages/spec/src/api/error-code-ledger.zod.ts`: the new code widens the published `ErrorCode` vocabulary, so a contract-tier review is owed before the queue. Clause-②: yes (widening) Metadata-protocol: none What Clause-② widens: the published error-code vocabulary. `ErrorCode` (the union `ApiErrorSchema.code` parses against) gains one registered member, `QUERY_ROW_BUDGET_EXCEEDED`. A page size over the maximum answers the existing standard `MAX_RECORDS_EXCEEDED` (400), which gains its first producer. No metadata an author writes changes, and no existing code, status or response shape moves. _Keep one value on each of the two lines above, each line starting with its key. [Human Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum) item 5 says what they mean._ ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QUs3BtTosRanM94rtudkMk --- _Generated by [Claude Code](https://claude.ai/code/session_01QUs3BtTosRanM94rtudkMk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4b71a82 commit b64980a

15 files changed

Lines changed: 1081 additions & 9 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/types': patch
3+
'@objectstack/spec': patch
4+
'@objectstack/metadata-protocol': patch
5+
'@objectstack/service-analytics': patch
6+
---
7+
8+
feat(data): a host-configured default and maximum page size for list, report and analytics reads, and a per-query row budget, refused with a code that names the limit
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: none
12+
13+
What Clause-② widens: the published error-code vocabulary. `ErrorCode` (the union `ApiErrorSchema.code` parses against) gains one registered member, `QUERY_ROW_BUDGET_EXCEEDED`. A page size over the maximum answers the existing standard `MAX_RECORDS_EXCEEDED` (400), which gains its first producer. No metadata an author writes changes, and no existing code, status or response shape moves.
14+
15+
A deployment can now bound how many rows one data API read returns and walks. Three environment variables, each a positive integer; all three unset is today's behaviour exactly, so a self-hosted deployment that sets none of them answers as before:
16+
17+
- **`OS_DATA_DEFAULT_PAGE_SIZE`** — the page a read gets when it names no `limit`. Unset with a maximum set, the maximum is the default.
18+
- **`OS_DATA_MAX_PAGE_SIZE`** — the largest `limit` a read may name.
19+
- **`OS_DATA_QUERY_ROW_BUDGET`** — the most rows one query may walk: its `offset` plus the rows it returns.
20+
21+
**Over a limit is refused, never clamped.** A clamp answers a different page from the one asked for, and a caller paging by its own `limit` would skip the rows in between. So a `limit` over the maximum answers `400 MAX_RECORDS_EXCEEDED`, and an `offset` plus `limit` over the budget answers `400 QUERY_ROW_BUDGET_EXCEEDED`. Both messages name the configured number, and both are refused before the driver is asked. Each refusal writes one `warn` line with the surface, the object (or analytics cube), the row count and the limit, and never a row value.
22+
23+
**A read that names no limit:**
24+
25+
- A list read (`GET /data/:object`, `POST /data/:object/query`, grouped or not) runs with the default page size as its `limit`. Its answer already says when more rows exist (`hasMore`, `total`), so this is an ordinary first page.
26+
- An analytics read (`query()` behind `/analytics/query`, and `queryDataset()`, the dataset door reports and dashboards read through) has no `hasMore`, so it is never cut silently. It runs with one row more than the default page size, and an answer with more rows than the default is refused with `MAX_RECORDS_EXCEEDED`, telling the caller to name a `limit` and page with `offset`.
27+
- With a row budget and no page size, an unpaged read runs with one row more than the budget leaves after its `offset`; an answer over the budget is refused with `QUERY_ROW_BUDGET_EXCEEDED`, and one that fits is served whole.
28+
29+
The budget counts the rows a query walks through its window. Rows a database scans inside an aggregate or behind an unindexed filter are not visible to the data API and are not what it counts. The analytics doors judge the caller's own window; the dataset executor's internal passes (totals, measure-scoped queries) are not judged separately.
30+
31+
A value that is not a positive integer, or a default above the maximum, fails at construction of the protocol and of the analytics service, naming the variable, so a typo never leaves the guard silently off.
32+
33+
`@objectstack/types` exports the rule (`resolveQueryReadLimits`, `planQueryReadWindow`, `assertQueryReadRows`, `QueryReadLimitError` with one subclass per code, `QueryPageSizeExceededError` and `QueryRowBudgetExceededError`, and the variable and code names). `QUERY_ROW_BUDGET_EXCEEDED` is registered in the error-code ledger, and the error catalog's `MAX_RECORDS_EXCEEDED` entry names the page-size cause.

‎content/docs/api/data-api.mdx‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,32 @@ raw rows** with nothing to say the aggregation never happened.
212212
}
213213
```
214214

215+
#### Read limits a deployment may set
216+
217+
A deployment can bound how many rows one read returns and walks. Three host
218+
environment variables, each a positive integer; with none of them set, no read
219+
is limited:
220+
221+
| Variable | Meaning |
222+
|:--|:--|
223+
| `OS_DATA_DEFAULT_PAGE_SIZE` | the page a read gets when it names no `top` / `limit` (unset with a maximum set: the maximum) |
224+
| `OS_DATA_MAX_PAGE_SIZE` | the largest `top` / `limit` a read may name |
225+
| `OS_DATA_QUERY_ROW_BUDGET` | the most rows one query may walk: its `skip` / `offset` plus the rows it returns |
226+
227+
Over a limit is **refused, never clamped**, so a client paging by its own page
228+
size never skips rows. A page size over the maximum answers
229+
`400 MAX_RECORDS_EXCEEDED`; `offset` plus page size over the budget answers
230+
`400 QUERY_ROW_BUDGET_EXCEEDED`. Both messages name the configured number, and
231+
both are refused before the read runs. A list read that names no page size gets
232+
the default page, and `hasMore` / `total` say whether more rows exist. With a
233+
budget and no page size, a read that names no page size runs one row past what
234+
the budget leaves and is refused with `QUERY_ROW_BUDGET_EXCEEDED` if its answer
235+
holds more; that read does run first. The same limits apply to
236+
`POST /analytics/query` and the dataset query that reports and dashboards read
237+
through. An analytics answer carries no `hasMore`, so one that names no `limit`
238+
and would answer more rows than the default page runs one row past the default
239+
and is refused with `MAX_RECORDS_EXCEEDED` rather than cut.
240+
215241
### `GET /data/:object/:id`
216242

217243
Get a single record by ID. Only `select` and `expand` query parameters are allowed; any other parameter is refused `400 VALIDATION_ERROR`, and the message names it and lists the two accepted ones.

‎content/docs/api/error-catalog.mdx‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ navTitle: Error Code Catalog
44
description: Complete reference for all ObjectStack error codes with causes, fixes, and retry strategies
55
---
66

7-
ObjectStack uses a structured error system of **error categories** and **56 error codes reachable on the wire**, catalogued in full below (that count is pinned by `packages/spec/src/api/error-catalog-docs.test.ts` against the wire face). Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
7+
ObjectStack uses a structured error system of **error categories** and **57 error codes reachable on the wire**, catalogued in full below (that count is pinned by `packages/spec/src/api/error-catalog-docs.test.ts` against the wire face). Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
88

99
This catalog documents the **wire face** — the codes a client can actually receive. That is not quite the
1010
`StandardErrorCode` enum: the enum also carries in-process spellings the REST door translates at the
@@ -367,8 +367,24 @@ result set — a response indistinguishable from a successful query.
367367
**Retry:** `no_retry`
368368

369369
### `MAX_RECORDS_EXCEEDED`
370-
**Cause:** The request attempts to create or return more records than the system limit.
371-
**Fix:** Use pagination (`limit`/`offset` or cursor-based) for large datasets. Batch operations for bulk creates.
370+
**Cause:** The request asked for more records than a request may carry. The data
371+
API answers it when the deployment sets `OS_DATA_MAX_PAGE_SIZE` and a list or
372+
analytics read named a `limit` (`$top`, `top` or `limit`) above it, and when an
373+
analytics read that named no `limit` would answer more rows than
374+
`OS_DATA_DEFAULT_PAGE_SIZE`. The message names the maximum (or the default).
375+
**Fix:** Name a `limit` within the maximum and page with `offset` (or a cursor).
376+
See [Read limits a deployment may set](/docs/api/data-api#read-limits-a-deployment-may-set).
377+
**Why it is an error and not a shorter page:** a clamped page is a different
378+
page from the one asked for, so a caller paging by its own `limit` would skip
379+
rows, and an analytics answer has no `hasMore` to say it was cut.
380+
**Retry:** `no_retry`
381+
382+
### `QUERY_ROW_BUDGET_EXCEEDED`
383+
**Cause:** The deployment sets `OS_DATA_QUERY_ROW_BUDGET` and the read's
384+
`offset` plus its rows would walk more than that budget. The message names the
385+
budget.
386+
**Fix:** Narrow the read with a filter, or use a smaller `offset` and `limit`.
387+
See [Read limits a deployment may set](/docs/api/data-api#read-limits-a-deployment-may-set).
372388
**Retry:** `no_retry`
373389

374390
---

‎content/docs/references/api/automation-api.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ const result = AutomationApiErrorCode.parse(data);
417417

418418
| Property | Type | Required | Description |
419419
| :--- | :--- | :--- | :--- |
420-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +356 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
420+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +357 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
421421
| **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |
422422

423423
### Allowed Values: `FlowInnerRefusal.code`
@@ -693,6 +693,7 @@ const result = AutomationApiErrorCode.parse(data);
693693
* `PROJECT_PROVISIONING`
694694
* `PROJECT_PROVISIONING_FAILED`
695695
* `QUERY_OBJECT_MISMATCH`
696+
* `QUERY_ROW_BUDGET_EXCEEDED`
696697
* `QUEUE_NOT_FOUND`
697698
* `RAW_SQL_UNSUPPORTED`
698699
* `READ_BACK_FAILED`

‎content/docs/references/api/contract.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +356 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +357 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3232
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -311,6 +311,7 @@ const result = ApiErrorSchema.parse(data);
311311
* `PROJECT_PROVISIONING`
312312
* `PROJECT_PROVISIONING_FAILED`
313313
* `QUERY_OBJECT_MISMATCH`
314+
* `QUERY_ROW_BUDGET_EXCEEDED`
314315
* `QUEUE_NOT_FOUND`
315316
* `RAW_SQL_UNSUPPORTED`
316317
* `READ_BACK_FAILED`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ const result = ErrorCode.parse(data);
478478
* `PROJECT_PROVISIONING`
479479
* `PROJECT_PROVISIONING_FAILED`
480480
* `QUERY_OBJECT_MISMATCH`
481+
* `QUERY_ROW_BUDGET_EXCEEDED`
481482
* `QUEUE_NOT_FOUND`
482483
* `RAW_SQL_UNSUPPORTED`
483484
* `READ_BACK_FAILED`

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
} from '@objectstack/spec/api';
99
import { IDataEngine, engineCanRollBack, recordNotFoundError } from '@objectstack/core';
1010
import { declaredUserMessage, resolveTenancyPosture, resolveThrownHttpError } from '@objectstack/types';
11+
import { assertQueryReadRows, planQueryReadWindow, resolveQueryReadLimits } from '@objectstack/types';
1112
import { type TenancyPosture } from '@objectstack/spec/security';
1213
// [commit 376c70f98] The derived `version` this file's `getDiscovery()` serves as the
1314
// `DiscoverySchema` "System Identity" field — never a literal again.
@@ -1269,6 +1270,10 @@ export class ObjectStackProtocolImplementation implements
12691270
this.getServicesRegistry = getServicesRegistry;
12701271
this.environmentId = environmentId;
12711272
this.authoringChannel = authoringChannel;
1273+
// Fail at construction on a misconfigured read limit (`OS_DATA_*`), so a
1274+
// typo is a boot fault naming the variable rather than a 500 on every
1275+
// list read. `findData` reads the limits again per call.
1276+
resolveQueryReadLimits();
12721277
}
12731278

12741279
// [#25467 E1] The one place that can read this class's private members builds
@@ -6248,7 +6253,23 @@ export class ObjectStackProtocolImplementation implements
62486253
// honor groupBy/aggregations in find().
62496254
const hasGroupBy = Array.isArray(options.groupBy) && options.groupBy.length > 0;
62506255
const hasAggregations = Array.isArray(options.aggregations) && options.aggregations.length > 0;
6256+
// The deployment's read limits (`OS_DATA_DEFAULT_PAGE_SIZE`,
6257+
// `OS_DATA_MAX_PAGE_SIZE`, `OS_DATA_QUERY_ROW_BUDGET`; the rule is
6258+
// `@objectstack/types`' `query-read-limits.ts`). Planned here, after
6259+
// every shape gate and before the engine, so a refusal leaves the
6260+
// driver unasked. All three unset returns the caller's own `limit`.
6261+
const readLimits = resolveQueryReadLimits();
62516262
if (hasGroupBy || hasAggregations) {
6263+
// The grouped branch slices in memory and never reads `offset`, so
6264+
// the window it is judged on is the groups it returns.
6265+
const groupRead = {
6266+
surface: 'list' as const,
6267+
object: request.object,
6268+
limit: typeof options.limit === 'number' ? options.limit : undefined,
6269+
limitParam: slotParam('limit'),
6270+
};
6271+
const groupPlan = planQueryReadWindow(groupRead, readLimits, console);
6272+
if (!groupPlan.probe) options.limit = groupPlan.limit;
62526273
const aggregateQuery: EngineAggregateOptions = {
62536274
where: options.where,
62546275
groupBy: options.groupBy,
@@ -6270,6 +6291,7 @@ export class ObjectStackProtocolImplementation implements
62706291
context: options.context,
62716292
};
62726293
const records = await this.engine.aggregate(request.object, aggregateQuery);
6294+
assertQueryReadRows(groupRead, groupPlan, records.length, console);
62736295
// Apply limit client-side (EngineAggregateOptions doesn't carry limit).
62746296
// `records` is the full grouped set, so its length IS the real total
62756297
// and `hasMore` follows from whether the slice dropped any groups.
@@ -6338,6 +6360,21 @@ export class ObjectStackProtocolImplementation implements
63386360
// projection named only the body, and taken back off before serving.
63396361
const bodyProjection = storedMetadataBodyProjection(request.object, options.fields);
63406362
if (bodyProjection.addedType) options.fields = bodyProjection.fields;
6363+
// A list read with no `limit` takes the default page size, and its
6364+
// answer says whether more rows exist (`hasMore` / `total` below). A
6365+
// PROBE limit (a row budget with no page size) is the server's, not the
6366+
// caller's: the answer is refused when it overruns, and otherwise it is
6367+
// the whole set, so the probe is taken back off before the page
6368+
// metadata below reads `limit`.
6369+
const listRead = {
6370+
surface: 'list' as const,
6371+
object: request.object,
6372+
limit: typeof options.limit === 'number' ? options.limit : undefined,
6373+
offset: typeof options.offset === 'number' ? options.offset : undefined,
6374+
limitParam: slotParam('limit'),
6375+
};
6376+
const listPlan = planQueryReadWindow(listRead, readLimits, console);
6377+
if (listPlan.limit !== undefined) options.limit = listPlan.limit;
63416378
// [#29764] The bag `engine.find` receives is typed as the engine declares
63426379
// it. `options` is the caller's normalized request bag, and by this line
63436380
// every key the normalizer does not hand on has been folded, deleted or
@@ -6361,11 +6398,16 @@ export class ObjectStackProtocolImplementation implements
63616398
// is served in keyed form — never the stored value, which beside the
63626399
// projected body confirms a guess at the withheld material offline —
63636400
// under the provider's key, or the process-scoped ephemeral one.
6401+
const found = await this.engine.find(request.object, findQuery);
6402+
if (listPlan.probe) {
6403+
assertQueryReadRows(listRead, listPlan, found.length, console);
6404+
delete options.limit;
6405+
}
63646406
const records = await serveStoredMetadataHashColumnRows(
63656407
request.object,
63666408
redactStoredMetadataRows(
63676409
request.object,
6368-
await this.engine.find(request.object, findQuery),
6410+
found,
63696411
{ dropType: bodyProjection.addedType },
63706412
),
63716413
this.storedHashDigest(),

0 commit comments

Comments
 (0)