Skip to content

Commit b76aad5

Browse files
fix(client)!: every limit query-parameter emitter sends what the caller wrote (#20060)
Fixes #19567 Clause-②: no (narrowing) ## What this does `@objectstack/client` set the `limit` query parameter behind three different guards, so one input got a different answer depending on the method. All 14 emitters in `packages/client/src/index.ts` now use `!== undefined`. Only an absent value stays off the wire. `0`, `NaN` and an untyped `null` leave the client exactly as written, and the door that declares the bound decides. The SDK itself still does not validate `limit`, per the ruling: an SDK that refuses `0` on its own is the wider change triage said must be raised separately. Every change is a narrowing: - The 3 truthy emitters now send `0`, `NaN` and `null` instead of dropping them. - The 4 `!= null` emitters now send `null` instead of dropping it. - The 7 `!== undefined` emitters are byte-unchanged. ⚠️ **The card's second complaint is still true, and now it is uniform.** `{ limit: null }` goes out as the text `null` on all 14 emitters, where before it went out on 7. Every `limit` here is typed `number | undefined`, so `null` only arrives from an untyped caller. The doors that parse `limit` refuse it with `400 invalid_number`. Dropping `null` instead would turn those 7 emitters from `400` to `200` with a default page, which is a widening, and the stop valve says not to ship that. That route was not taken. No in-repo caller passes `null` or `0` to any of these methods, and neither does objectui. I checked both: `apps/console/src/pages/developer/FlowRunsPage.tsx:451` passes `limit: 20`, `plugin-grid/src/ImportWizard.tsx:1484` passes `limit: 50`, and `useClientNotifications.ts:157` passes nothing. That was objectui `main` at `961ceaa`. The `automation.runs.list` docblock said "`0` and `NaN` are the exception, and they are dropped rather than refused: the guard below is truthy". That is no longer true, so it is rewritten. Every other docblock and README line on these methods is still true. I searched `packages/client/**` and `content/docs/**`, and the only hand-written statement of the SDK's `limit` handling was that one docblock. ## Census, re-run on `origin/main` `5581d300` The PM counted 13 emitters; there are **14**. The 14th spells the key inside a template literal rather than through `.set('limit', …)`: `meta.getAudit` at `:2143`, with the guard `opts?.limit !== undefined ? ` followed by `` `?limit=${opts.limit}` ``. It was already `!== undefined`, so the route leaves it unchanged. It is in the census and in the test table. The PM's other correction holds: the two `listImportJobs` rows (`:7086`, `:7932`) are client emitters, not server-side reads. | # | method | line at `5581d300` | guard before | guard after | |--:|:--|--:|:--|:--| | 1 | `meta.getHistory` | 1933 | `!== undefined` | `!== undefined` | | 2 | `meta.getAudit` (template literal) | 2143 | `!== undefined` | `!== undefined` | | 3 | `environments.listRevisions` | 3243 | truthy | `!== undefined` | | 4 | `automation.runs.list` | 5563 | truthy | `!== undefined` | | 5 | `automation.listRuns` | 5642 | `!= null` | `!== undefined` | | 6 | `search` | 6344 | `!== undefined` | `!== undefined` | | 7 | `notifications.list` | 6482 | truthy | `!== undefined` | | 8 | `ai.conversations.list` | 6652 | `!== undefined` | `!== undefined` | | 9 | `ai.pendingActions.list` | 6782 | `!== undefined` | `!== undefined` | | 10 | `data.listImportJobs` | 7086 | `!= null` | `!== undefined` | | 11 | `data.export` | 7290 | `!== undefined` | `!== undefined` | | 12 | `environment(id).meta.getHistory` | 7788 | `!== undefined` | `!== undefined` | | 13 | `environment(id).data.listImportJobs` | 7932 | `!= null` | `!== undefined` | | 14 | `environment(id).automation.listRuns` | 8126 | `!= null` | `!== undefined` | **Instrument.** `git grep -n limit -- packages/client/src/index.ts` returned 48 lines, each read by hand. I then ran two narrower patterns over the same file: `.set('limit',` gives 13 hits, and a template `?limit=` followed by an interpolation gives 1 hit. The file has no `URLSearchParams` built from an object and no `Object.entries` loop that could set `limit` through a variable. The two `Object.entries` loops at `:6978` and `:7854` expand a `find` filter object into field filters. **Lit control.** The same instrument also finds three look-alikes, and I excluded each one by reading it: - `data.find` maps `limit` to the `top` key (`:6917`, `:7825`), so it is a different wire key. - `:4024` hard-codes `&limit=1`, so it is not caller-driven. - `query-builder.ts:285` writes a QueryAST member for a POST body, not a query parameter. No other file in `packages/client/src` emits `limit`, so the family stays inside this one file and no helper was added. ## What each door does with the value In-repo doors were measured by reading the handler and then running the door's own parse against each spelling. The probe imported the real `parseIntegerParam` from `packages/runtime/src/query-param.ts` and evaluated the other doors' inline expressions. Excerpts are in the report. | # | route | where `limit` is declared | `0` | `NaN` / `null` | before → after | |--:|:--|:--|:--|:--|:--| | 1, 12 | `GET /api/v1/meta/:type/:name/history` (and its `/environments/:id` mount) | `HistoryMetaItemRequestSchema.limit` = `z.number().optional()`, deliberately unbounded (`spec api/protocol.zod.ts:1532`) | `200`, zero events (`sys-metadata-repository.ts:1208`) | dropped by `Number.isFinite` (`rest-server.ts:7708`), `200` with the whole log | unchanged | | 2 | `GET /api/v1/meta/:type/:name/audit` | `AuditMetaItemRequestSchema.limit`, clamp `[1, 500]`, "never refused" (`:1421`) | clamped to 1 (`protocol.ts:8906`) | dropped (`rest-server.ts:7877`), default 100 | unchanged | | 3 | `GET /api/v1/cloud/environments/:id/revisions` | not in this tree: control plane, `objectstack-ai/cloud` | **NOT MEASURED** | **NOT MEASURED** | was dropped, now sent; the door's answer is NOT MEASURED | | 4 | `GET /api/v1/automation/:name/runs` | `ListRunsRequestSchema.limit` = `int().min(1).max(100).default(20)` (`api/automation-api.zod.ts:569`) | `400 VALIDATION_FAILED`, `min_value` | `400 VALIDATION_FAILED`, `invalid_number` | all three: `200` with the default 20 → `400` | | 5, 14 | same door (and its `/environments/:id` mount) | same | `400` (already sent) | `NaN` `400` (already sent); `null` was dropped | `null`: `200` with the default → `400` | | 6 | `GET /api/v1/search` | no request schema; parameter allow-list `GLOBAL_SEARCH_PARAMS` (`rest-server.ts:10236`) | overall cap clamped to 1 | overall cap becomes `NaN`, so there is **no overall cap** (`protocol.ts:11756`, `:11805`); see the notes | unchanged | | 7 | `GET /api/v1/notifications` | `ListNotificationsRequestSchema.limit` = `z.number().optional()`, declared clamp into 1..200 (`:2781`) | `200`, one row (`messaging-service.ts:541`) | `400 VALIDATION_FAILED`, `invalid_number` (`notifications.ts:75`) | `0`: 50 rows → 1 row; `NaN` / `null`: `200` → `400` | | 8 | `GET /api/v1/ai/conversations` | `ListAiConversationsRequestSchema.limit` = `int().positive().optional()` (`:2942`) | NOT MEASURED (cloud `service-ai`) | NOT MEASURED | unchanged | | 9 | `GET /api/v1/ai/pending-actions` | `ListAiPendingActionsRequestSchema.limit` = `int().positive().optional()` (`:3078`) | NOT MEASURED (cloud `service-ai`) | NOT MEASURED | unchanged | | 10, 13 | `GET /api/v1/data/import/jobs` (and its `/environments/:id` mount) | `ListImportJobsRequestSchema.limit` = `int().min(1).max(200).default(50)` (`api/export.zod.ts:503`) | swapped for 50 (`rest-server.ts:9714`) | swapped for 50 | `null`: dropped → sent, but both answer 50, so **inert on the wire** | | 11 | `GET /api/v1/data/:object/export` | no request schema for this GET; the route comment says the default is 10000 and the hard cap is 50000 (`rest-server.ts:9758`) | a one-row export (`rest-server.ts:9841`) | a one-row export | unchanged | `NOT MEASURED: cloud doors (rows 3, 8, 9), reason: served by objectstack-ai/cloud, which this container cannot read (git ls-remote asks for credentials).` Only row 3 changes on the wire, and for row 3 the client-side change follows the ruling whatever the door answers. Its `0` / `NaN` / `null` answer should be read in that repo. The door-side gaps above (rows 6, 10, 11, and row 1 on `NaN`) were **not** touched here. This card is the SDK half. They are in the acceptance notes. ## Tests - New: `packages/client/src/limit-guard-family.test.ts` drives all 14 methods through the real `ObjectStackClient` against a stubbed `fetch`, and reads the URL each one requested. For every emitter it checks five inputs: `undefined` is not sent; `20`, `0` and `NaN` are sent as written; and an untyped `null` is sent as `null`. A census row counts the emitters in `index.ts` against the table, so a new emitter has to join it. Result: `Tests 71 passed (71)`. - **Mutation leg.** I committed the change first (`441ecced`), then restored the truthy guard on `automation.runs.list` through `scripts/ablation-replace.mjs`. The anchor hit exactly once; the anchor count went 1 → 0, the replacement count 0 → 1, and the blob `236492ae9044` → `d2feaa80a8f4`. Result: `Tests 3 failed | 68 passed (71)`, and exactly the three `automation.runs.list` rows went red (`0`, `NaN`, `null`). The restore is proven: the blob after restore equals the HEAD blob `236492ae9044`, and `git diff HEAD` is empty. The script also carried its own `trap` restore. - `pnpm --filter @objectstack/client exec vitest run`: `Test Files 50 passed (50)`, `Tests 640 passed (640)`. - `pnpm --filter @objectstack/client run typecheck` (`tsc --noEmit` plus the test-layer program): exit 0, "0 file(s) / 0 error(s)". - Tests and typecheck ran at `441ecced`. `git diff 441ecce ba1c3ea -- packages/` is empty: the two later commits touch only the changeset. ## Gates, at `ba1c3eae` - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 60 commands from the real diff, the same list the PM derived. All 60 exit 0, and `--ran` reconciliation reports "60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)". - `check:skill-examples` and `check:dual-build-cjs-loads` first exited **3**, "PREREQUISITE NOT MET: no dist", in the pre-build pass. After `turbo run build --filter=!@objectstack/docs --concurrency=2`, both exit 0. `dual-build-cjs-loads` reports "104 published require entry point(s) across 67 package(s) load". - `check:adr-0087-registration --base origin/main`: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", with the disposition `not-required (no-migration-prescription)`. - `pnpm lint`, the full union `eslint . --no-inline-config`: exit 0 at `ba1c3eae`. ## Changeset `.changeset/19567-client-limit-guards.md`: `@objectstack/client` **minor**, with a BREAKING banner (a call that used to answer `200` can now answer `400`), the ADR-0087 disposition `not-required (no-migration-prescription)`, and `Clause-②: no (narrowing)` copied from the claim. It is not a patch, because a narrowing ships at least `minor`. ## Acceptance notes - **Premise corrections, measured.** The family is 14, not 11 (the card) or 13 (the dispatch); the 14th is the template-literal `getAudit`. Triage's comment cites `api/automation-api.zod.ts:66` as the bound behind `automation.runs.list`, but `:66` is `ListFlowsRequestSchema` (`GET /automation`). The runs door's bound is `:569`: `min(1).max(100).default(20)`. The direction of the ruling is unaffected. - **Other numeric keys with their own guard, deliberately untouched (card scope).** - `offset` on `data.listImportJobs` (`:7088`) and on its scoped twin (`:7934`) still uses `!= null`, so `null` is dropped there while the sibling `limit` on the same call now sends it. The door reads both as its default either way (`Number(q.offset) || 0`). Class: none of a/b/c, and no carrier. - `top` / `skip` on `data.find` (`:6947`, `:6948`, `:7837`, `:7838`) and the canonical normalizer's `limit` / `offset` (`:6917`, `:6918`, `:7825`, `:7826`) use `!= null` by an earlier deliberate choice ("PRESENCE, not truthiness"). An untyped `limit: null` on `find` therefore sends no `top`, which on that open GET route means the entire match set. `null` is outside the declared type. Class: none of a/b/c, and no carrier. - `sinceSeq` and `perObject` already use `!== undefined`. - **Door-side findings, reported for the seat to file; this PR leaves them as they are.** - (a) A non-numeric `?limit=` is silently widened or substituted on the doors that do not go through `parseIntegerParam`. `GET /api/v1/search` loses its overall cap (`Math.max(1, Math.min(100, NaN))` is `NaN`, and a hit count compared against `NaN` never reaches it). `GET /data/:object/export` answers a one-row export. `GET /meta/:type/:name/history` answers the whole change log. All three return `200`. - (b) `GET /data/import/jobs` declares `limit` `int().min(1).max(200)` and refuses nothing: `?limit=0` answers the default 50 rows and `?limit=500` answers 200. Seam: `spec:ListImportJobsRequestSchema.limit` → `runtime:packages/rest/src/rest-server.ts:9714`. - #19543's doors (`cursor`, `hasMore`, pagination semantics) are untouched; only the `limit` guards moved. --- _Generated by [Claude Code](https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5b9402d commit b76aad5

3 files changed

Lines changed: 162 additions & 13 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@objectstack/client': minor
3+
---
4+
5+
fix(client)!: every `limit` query-parameter emitter sends what the caller wrote, so `{ limit: 0 }` is no longer silently swapped for the server's default window on three methods (#19567)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a runtime behaviour change in the SDK's query-string building: no spec key, export, type or stored shape is added, removed or renamed, so objectstack migrate meta has nothing to rewrite, and the one caller-side adjustment is to leave limit out rather than pass 0 or null -->
10+
11+
**BREAKING** — a narrowing, shipped as `minor` under the launch-window convention
12+
(`check-changeset-no-major` refuses `major` until GA; the breaking-ness is carried by
13+
this banner and the ADR-0087 disposition above, not by the level). A call that used
14+
to answer `200` can now answer `400`.
15+
16+
**What changed.** The SDK set the `limit` query parameter behind three different
17+
guards, so one input got a different answer depending on the method. Seven methods
18+
already sent every value except `undefined`. Three used a truthy test, so `0` and
19+
`NaN` never left the client and the server answered `200` with its default window —
20+
rows the caller did not ask for. Four used `!= null`, which dropped an untyped `null`
21+
as the truthy three did, while the seven others sent it as the text `null`. All
22+
fourteen emitters now leave only an absent (`undefined`) `limit` off the wire and send
23+
everything else as written; the door that declares the bound decides. The SDK itself
24+
still does not validate `limit`.
25+
26+
| method | what changes on the wire |
27+
|:--|:--|
28+
| `automation.runs.list` | `0`, `NaN` and `null` are now sent; the door declares `1..100` and refuses all three with `400 VALIDATION_FAILED` |
29+
| `notifications.list` | `0`, `NaN` and `null` are now sent; the inbox clamps `0` to one row (its declared clamp into `1..200`) and refuses `NaN` / `null` with `400` |
30+
| `environments.listRevisions` | `0`, `NaN` and `null` are now sent to the control-plane door |
31+
| `automation.listRuns`, `environment(id).automation.listRuns` | an untyped `null` is now sent and refused with `400` (`0` and `NaN` were already sent) |
32+
| `data.listImportJobs`, `environment(id).data.listImportJobs` | an untyped `null` is now sent; the door reads it as its default of 50, so the answer does not move |
33+
34+
`meta.getHistory`, `meta.getAudit`, `search`, `ai.conversations.list`,
35+
`ai.pendingActions.list`, `data.export` and `environment(id).meta.getHistory`
36+
already sent every value except `undefined`, and are unchanged.
37+
38+
**If you relied on the old behaviour:** a call that passed `limit: 0` (or `null`) to
39+
mean "the server's default window" should leave `limit` out instead. Every `limit`
40+
here is typed `number | undefined`, so `null` only reaches these methods through an
41+
untyped caller.

‎packages/client/src/index.ts‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3240,7 +3240,7 @@ export class ObjectStackClient {
32403240
*/
32413241
listRevisions: async (id: string, opts?: { limit?: number; cursor?: string; branch?: string }) => {
32423242
const params = new URLSearchParams();
3243-
if (opts?.limit) params.set('limit', String(opts.limit));
3243+
if (opts?.limit !== undefined) params.set('limit', String(opts.limit));
32443244
if (opts?.cursor) params.set('cursor', opts.cursor);
32453245
if (opts?.branch) params.set('branch', opts.branch);
32463246
const qs = params.toString();
@@ -5547,20 +5547,21 @@ export class ObjectStackClient {
55475547
* REFUSED with `400 VALIDATION_FAILED`, never clamped — so raise it
55485548
* deliberately to see further back.
55495549
*
5550-
* ⚠️ `0` and `NaN` are the exception, and they are dropped rather
5551-
* than refused: the guard below is truthy, so a falsy `limit` never
5552-
* leaves the client and the server answers its DEFAULT window
5553-
* instead. `-5`, `1.5` and `101` are truthy, are sent, and are
5554-
* refused. The two `listRuns` surfaces guard on `!= null` and do
5555-
* send `0`.
5550+
* There is no exception: the SDK does not judge `limit`, it sends
5551+
* whatever it is given and leaves only an ABSENT (`undefined`) value
5552+
* off the wire. `0`, `NaN`, `-5`, `1.5` and `101` all reach the door
5553+
* and are all refused there — none of them is swapped for the
5554+
* default window. An untyped `null` is outside the declared type; it
5555+
* is sent as the text `null` and refused as not a whole number. The
5556+
* two `listRuns` surfaces guard the same way.
55565557
*
55575558
* There is no continuation token — read `hasMore` to learn whether
55585559
* the window was short.
55595560
*/
55605561
list: async (flowName: string, options?: { limit?: number }): Promise<{ runs: ExecutionLog[]; hasMore: boolean }> => {
55615562
const route = this.getRoute('automation');
55625563
const params = new URLSearchParams();
5563-
if (options?.limit) params.set('limit', String(options.limit));
5564+
if (options?.limit !== undefined) params.set('limit', String(options.limit));
55645565
const qs = params.toString();
55655566
const res = await this.fetch(`${this.baseUrl}${route}/${flowName}/runs${qs ? `?${qs}` : ''}`);
55665567
return this.unwrapResponse(res);
@@ -5639,7 +5640,7 @@ export class ObjectStackClient {
56395640
): Promise<T> => {
56405641
const route = this.getRoute('automation');
56415642
const params = new URLSearchParams();
5642-
if (opts?.limit != null) params.set('limit', String(opts.limit));
5643+
if (opts?.limit !== undefined) params.set('limit', String(opts.limit));
56435644
// [#7359] The route's declared `status` filter, now that the boundary
56445645
// honours it instead of dropping it. Until this card the typed client
56455646
// could not send it at all — which is why nothing had tripped over the
@@ -6479,7 +6480,7 @@ export class ObjectStackClient {
64796480
const params = new URLSearchParams();
64806481
if (options?.read !== undefined) params.set('read', String(options.read));
64816482
if (options?.type) params.set('type', options.type);
6482-
if (options?.limit) params.set('limit', String(options.limit));
6483+
if (options?.limit !== undefined) params.set('limit', String(options.limit));
64836484
const qs = params.toString();
64846485
const res = await this.fetch(`${this.baseUrl}${route}${qs ? `?${qs}` : ''}`);
64856486
return this.unwrapResponse<ListNotificationsResponse>(res);
@@ -7083,7 +7084,7 @@ export class ObjectStackClient {
70837084
const qs = new URLSearchParams();
70847085
if (query.object) qs.set('object', query.object);
70857086
if (query.status) qs.set('status', query.status);
7086-
if (query.limit != null) qs.set('limit', String(query.limit));
7087+
if (query.limit !== undefined) qs.set('limit', String(query.limit));
70877088
if (query.offset != null) qs.set('offset', String(query.offset));
70887089
const suffix = qs.toString() ? `?${qs.toString()}` : '';
70897090
const res = await this.fetch(`${this.baseUrl}${route}/import/jobs${suffix}`);
@@ -7929,7 +7930,7 @@ export class ScopedEnvironmentClient {
79297930
const qs = new URLSearchParams();
79307931
if (query.object) qs.set('object', query.object);
79317932
if (query.status) qs.set('status', query.status);
7932-
if (query.limit != null) qs.set('limit', String(query.limit));
7933+
if (query.limit !== undefined) qs.set('limit', String(query.limit));
79337934
if (query.offset != null) qs.set('offset', String(query.offset));
79347935
const suffix = qs.toString() ? `?${qs.toString()}` : '';
79357936
const res = await this.parent._fetch(this.dataUrl(`/import/jobs${suffix}`));
@@ -8123,7 +8124,7 @@ export class ScopedEnvironmentClient {
81238124
opts?: { limit?: number; status?: ExecutionStatus },
81248125
): Promise<T> => {
81258126
const params = new URLSearchParams();
8126-
if (opts?.limit != null) params.set('limit', String(opts.limit));
8127+
if (opts?.limit !== undefined) params.set('limit', String(opts.limit));
81278128
// [#7359] — see the sibling `listRuns` alias above.
81288129
if (opts?.status) params.set('status', opts.status);
81298130
const qs = params.toString();
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { readFileSync } from 'node:fs';
3+
import { ObjectStackClient } from './index';
4+
5+
/**
6+
* The `limit` query-parameter emitters of this SDK, pinned as ONE family (#19567).
7+
*
8+
* They used to guard three different ways — a truthy test, `!= null`, and
9+
* `!== undefined` — so one input got a different answer by method:
10+
* `{ limit: 0 }` was dropped by some (the server then answered `200` with its
11+
* DEFAULT window, a page the caller did not ask for) and sent by the others
12+
* (`400` from a door that declares `min(1)`); `null` was dropped by some and
13+
* sent by the rest.
14+
*
15+
* The rule now is the one the declared doors need: the SDK does not judge
16+
* `limit`. Only an ABSENT value (`undefined`) stays off the wire; `0`, `NaN`
17+
* and an untyped `null` all leave the client exactly as written, and the door
18+
* that declares the bound is the one that refuses. Every emitter is driven
19+
* through the real method against a stubbed `fetch`, and the assertion reads
20+
* the URL it actually requested.
21+
*
22+
* `null` is outside every emitter's declared type (`limit?: number`); it is
23+
* driven through a cast because an untyped caller can still pass it. Pinning
24+
* it as SENT is deliberate: dropping it again would turn the `400` those
25+
* doors answer today into a silent `200` with a default page.
26+
*/
27+
28+
type Emitter = {
29+
/** The public path a caller writes. */
30+
label: string;
31+
/** Drives the method with `{ limit }` (or its positional equivalent). */
32+
call: (client: ObjectStackClient, limit: number | undefined) => Promise<unknown>;
33+
};
34+
35+
const ENV = 'env_limit';
36+
37+
const EMITTERS: readonly Emitter[] = [
38+
{ label: 'meta.getHistory', call: (c, limit) => c.meta.getHistory('object', 'task', { limit }) },
39+
{ label: 'meta.getAudit', call: (c, limit) => c.meta.getAudit('object', 'task', { limit }) },
40+
{ label: 'environments.listRevisions', call: (c, limit) => c.environments.listRevisions(ENV, { limit }) },
41+
{ label: 'automation.runs.list', call: (c, limit) => c.automation.runs.list('my_flow', { limit }) },
42+
{ label: 'automation.listRuns', call: (c, limit) => c.automation.listRuns('my_flow', { limit }) },
43+
{ label: 'search', call: (c, limit) => c.search('acme', { limit }) },
44+
{ label: 'notifications.list', call: (c, limit) => c.notifications.list({ limit }) },
45+
{ label: 'ai.conversations.list', call: (c, limit) => c.ai.conversations.list({ limit }) },
46+
{ label: 'ai.pendingActions.list', call: (c, limit) => c.ai.pendingActions.list({ limit }) },
47+
{ label: 'data.listImportJobs', call: (c, limit) => c.data.listImportJobs({ limit }) },
48+
{ label: 'data.export', call: (c, limit) => c.data.export('task', { limit }) },
49+
{ label: 'environment().meta.getHistory', call: (c, limit) => c.environment(ENV).meta.getHistory('object', 'task', { limit }) },
50+
{ label: 'environment().data.listImportJobs', call: (c, limit) => c.environment(ENV).data.listImportJobs({ limit }) },
51+
{ label: 'environment().automation.listRuns', call: (c, limit) => c.environment(ENV).automation.listRuns('my_flow', { limit }) },
52+
];
53+
54+
/** The URL the method requested, after it has run to completion or failed on the stub body. */
55+
async function requestedUrl(emitter: Emitter, limit: number | undefined): Promise<URL> {
56+
const fetchMock = vi.fn().mockResolvedValue({
57+
ok: true,
58+
status: 200,
59+
statusText: 'OK',
60+
json: async () => ({ success: true, data: {} }),
61+
headers: new Headers(),
62+
});
63+
const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock });
64+
// Only the request matters here; a method that trips over the stub body
65+
// AFTER fetching has still told us what it sent.
66+
await emitter.call(client, limit).then(() => undefined, () => undefined);
67+
expect(fetchMock, `${emitter.label} never reached fetch`).toHaveBeenCalledTimes(1);
68+
return new URL(String(fetchMock.mock.calls[0][0]));
69+
}
70+
71+
describe('the limit emitter family sends what the caller wrote', () => {
72+
it.each(EMITTERS.map((e) => [e.label, e] as const))('%s: an absent limit stays off the wire', async (_label, emitter) => {
73+
const url = await requestedUrl(emitter, undefined);
74+
expect(url.searchParams.has('limit')).toBe(false);
75+
});
76+
77+
it.each(EMITTERS.map((e) => [e.label, e] as const))('%s: an ordinary limit is sent', async (_label, emitter) => {
78+
const url = await requestedUrl(emitter, 20);
79+
expect(url.searchParams.getAll('limit')).toEqual(['20']);
80+
});
81+
82+
it.each(EMITTERS.map((e) => [e.label, e] as const))('%s: limit 0 is sent, not swapped for the default window', async (_label, emitter) => {
83+
const url = await requestedUrl(emitter, 0);
84+
expect(url.searchParams.getAll('limit')).toEqual(['0']);
85+
});
86+
87+
it.each(EMITTERS.map((e) => [e.label, e] as const))('%s: limit NaN is sent, not swapped for the default window', async (_label, emitter) => {
88+
const url = await requestedUrl(emitter, Number.NaN);
89+
expect(url.searchParams.getAll('limit')).toEqual(['NaN']);
90+
});
91+
92+
it.each(EMITTERS.map((e) => [e.label, e] as const))('%s: an untyped null is sent as written, for the door to refuse', async (_label, emitter) => {
93+
const url = await requestedUrl(emitter, null as unknown as number);
94+
expect(url.searchParams.getAll('limit')).toEqual(['null']);
95+
});
96+
97+
it('drives every limit emitter in index.ts — a new one has to join this table', () => {
98+
// The census the table above must cover: every `set('limit', …)` call plus
99+
// the one emitter that spells the key inside a template literal. A new
100+
// emitter that is not added to EMITTERS changes this count, so it cannot
101+
// arrive with a fourth guard spelling unpinned.
102+
const source = readFileSync(new URL('./index.ts', import.meta.url), 'utf8');
103+
const setCalls = source.match(/\.set\('limit',/g) ?? [];
104+
const templateEmitters = source.match(/\?limit=\$\{/g) ?? [];
105+
expect(setCalls.length + templateEmitters.length).toBe(EMITTERS.length);
106+
});
107+
});

0 commit comments

Comments
 (0)