@startsimpli/integrations 0.2.32 → 0.2.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/integrations",
3
- "version": "0.2.32",
3
+ "version": "0.2.34",
4
4
  "description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,160 @@
1
+ /**
2
+ * THE CONNECTION CARD SAYS WHY A RUN FAILED (bd startsim-ei8gm.2, part 2).
3
+ *
4
+ * `ConnectionRun.error_message` has been DECLARED in this package's own types
5
+ * for as long as the endpoint has existed, and no component in
6
+ * `packages/integrations` or `apps/foundry` has ever read it. `connectionRuns`
7
+ * is likewise required on both api interfaces and called by nothing. So the
8
+ * activity endpoint that `#375` just taught to answer a real reason for source
9
+ * runs has no reader at all: a failed run does not render as a status word — it
10
+ * does not render.
11
+ *
12
+ * ONE COMPOSER, TWO SURFACES. `ConnectionsPage` is mounted by the control plane
13
+ * (`apps/foundry`'s `<ConnectionsPage api={api.foundry} foundrySlug={slug} />`)
14
+ * and by a tenant member's own screen in `personal` mode. The disclosure is
15
+ * gated on the VERB EXISTING — the rule the file already applies to Workflows
16
+ * and Settings — so an app pinned to an older `@startsimpli/api` degrades to no
17
+ * control rather than to a button whose handler is `undefined`.
18
+ *
19
+ * NOTHING IS SCRUBBED HERE. The text arrives already redacted against that
20
+ * connection's stored credential plus the platform app_secret
21
+ * (`apps/connectors/services/surface.py::_reason_reader` ->
22
+ * `dispatch.redaction_list`), and a run whose reason could not be proven clean
23
+ * arrives as a sentence saying so. A second redaction path in the browser would
24
+ * be a second thing to get wrong, and the browser does not hold the secrets to
25
+ * do it with.
26
+ */
27
+ import { describe, expect, it, vi } from 'vitest';
28
+ import { render, screen, waitFor } from '@testing-library/react';
29
+ import userEvent from '@testing-library/user-event';
30
+
31
+ import { ConnectionsPage } from '../components/pages/ConnectionsPage';
32
+ import type { ConnectionsApi, ConnectionRun } from '../components/pages/types';
33
+
34
+ const CATALOG = {
35
+ results: [
36
+ {
37
+ key: 'granola', name: 'Granola', description: 'Meeting notes',
38
+ logo_url: '', docs_url: '', capabilities: ['poll'], auth_mode: 'api_key',
39
+ credential_schema: { version: 1, fields: [] },
40
+ config_schema: { version: 1, fields: [] },
41
+ record_types: [], supports_webhooks: false,
42
+ availability: { available: true, reason: '' },
43
+ },
44
+ ],
45
+ };
46
+
47
+ const CONNECTED = {
48
+ results: [
49
+ {
50
+ id: 'c1', public_id: 'pub1', name: 'Tandem Granola',
51
+ connector_key: 'granola', connector_name: 'Granola',
52
+ external_account_id: '', account_label: 'tandem',
53
+ capabilities_enabled: ['poll'], config: {}, status: 'connected',
54
+ health: 'ok', auth_mode: 'api_key',
55
+ },
56
+ ],
57
+ permissions: { view: true, manage: true, issue_tokens: true },
58
+ };
59
+
60
+ /** What production held, verbatim, for each of the twenty failed polls. */
61
+ const THE_REAL_REASON =
62
+ 'connector_granola poll failed on page 1: [config_error] the response '
63
+ + 'exceeded the 262144-byte cap and was aborted [granola]';
64
+
65
+ const RUNS: ConnectionRun[] = [
66
+ {
67
+ id: 'r-new', kind: 'destination_run', object_id: 'd1', name: 'Corpus delivery',
68
+ status: 'completed', run_type: 'scheduled',
69
+ occurred_at: '2026-08-27T04:24:08Z', error_message: '',
70
+ },
71
+ {
72
+ id: 'r-old', kind: 'source_run', object_id: 's1', name: 'granola-poll',
73
+ status: 'failed', run_type: 'incremental',
74
+ occurred_at: '2026-08-12T20:21:46Z', error_message: THE_REAL_REASON,
75
+ },
76
+ ];
77
+
78
+ function stubApi(overrides: Partial<ConnectionsApi> = {}): ConnectionsApi {
79
+ return {
80
+ connectorCatalog: vi.fn().mockResolvedValue(CATALOG),
81
+ connections: vi.fn().mockResolvedValue(CONNECTED),
82
+ connect: vi.fn(),
83
+ checkConnection: vi.fn().mockResolvedValue({ ok: true, account_label: '', detail: '' }),
84
+ disconnect: vi.fn(),
85
+ connectionChoices: vi.fn(),
86
+ wireTarget: vi.fn(),
87
+ confirmTarget: vi.fn(),
88
+ connectionActions: vi.fn(),
89
+ invokeConnectionAction: vi.fn(),
90
+ connectionRuns: vi.fn().mockResolvedValue({ results: RUNS }),
91
+ updateConnection: vi.fn(),
92
+ rotateCredential: vi.fn(),
93
+ ...overrides,
94
+ } as unknown as ConnectionsApi;
95
+ }
96
+
97
+ async function openActivity(api: ConnectionsApi = stubApi()) {
98
+ render(<ConnectionsPage api={api} foundrySlug="tandem" />);
99
+ const button = await screen.findByRole('button', { name: /activity/i });
100
+ await userEvent.click(button);
101
+ return api;
102
+ }
103
+
104
+ describe('ConnectionCard — recent activity', () => {
105
+ it('has a control at all, which is the whole of the gap', async () => {
106
+ render(<ConnectionsPage api={stubApi()} foundrySlug="tandem" />);
107
+
108
+ expect(await screen.findByRole('button', { name: /activity/i })).toBeInTheDocument();
109
+ });
110
+
111
+ it('reads the runs endpoint that nothing has ever called', async () => {
112
+ const api = await openActivity();
113
+
114
+ await waitFor(() => expect(api.connectionRuns).toHaveBeenCalledWith('tandem', 'c1'));
115
+ });
116
+
117
+ it('renders the reason a failed run gives, not only its status word', async () => {
118
+ await openActivity();
119
+
120
+ expect(await screen.findByText(/262144-byte cap/)).toBeInTheDocument();
121
+ });
122
+
123
+ it('names which leg the run belongs to, since the card merges three tables', async () => {
124
+ await openActivity();
125
+
126
+ const activity = await screen.findByRole('group', { name: /recent activity/i });
127
+ expect(activity).toHaveTextContent(/granola-poll/);
128
+ expect(activity).toHaveTextContent(/Corpus delivery/);
129
+ });
130
+
131
+ it('invents no reason for a run that succeeded', async () => {
132
+ await openActivity(stubApi({
133
+ connectionRuns: vi.fn().mockResolvedValue({ results: [RUNS[0]] }),
134
+ }) as ConnectionsApi);
135
+
136
+ const activity = await screen.findByRole('group', { name: /recent activity/i });
137
+ expect(activity).not.toHaveTextContent(/262144/);
138
+ });
139
+
140
+ it('says so when there is no activity, rather than rendering an empty box', async () => {
141
+ await openActivity(stubApi({
142
+ connectionRuns: vi.fn().mockResolvedValue({ results: [] }),
143
+ }) as ConnectionsApi);
144
+
145
+ const activity = await screen.findByRole('group', { name: /recent activity/i });
146
+ expect(activity).toHaveTextContent(/no runs yet/i);
147
+ });
148
+
149
+ it('offers no control when the client is too old to have the verb', async () => {
150
+ // Apps consume `@startsimpli/*` as PUBLISHED packages, so a client pinned
151
+ // to an older `@startsimpli/api` is a normal state of the world. The gate
152
+ // is the verb existing — the same rule Workflows and Settings use.
153
+ const api = stubApi();
154
+ delete (api as Partial<ConnectionsApi>).connectionRuns;
155
+ render(<ConnectionsPage api={api} foundrySlug="tandem" />);
156
+
157
+ await screen.findByText('Tandem Granola');
158
+ expect(screen.queryByRole('button', { name: /activity/i })).not.toBeInTheDocument();
159
+ });
160
+ });
@@ -0,0 +1,337 @@
1
+ /**
2
+ * THE CLIENT ASKS THE SERVER FOR A NARROWER ANSWER, AND CHECKS THAT IT GOT ONE.
3
+ *
4
+ * Two bugs, one root cause (bd startsim-o0o6o.2, bd startsim-o0o6o.3). The
5
+ * corpus adapter was written against a tenant data plane that filtered on
6
+ * `?type=` and nothing else, and several of its comments still assert that as
7
+ * fact. It has not been true since bd startsim-768w.38.12 and bd startsim-te3r:
8
+ * `RelationshipQuery` applies `entity__in` bounded at `MAX_ID_LIST` ids, and
9
+ * `TenantPageNumberPagination` honours `page_size` up to 200.
10
+ *
11
+ * WHY THE ANSWER IS CHECKED AND NOT ASSUMED. The tenant's response to a filter
12
+ * it does not recognise is an UNFILTERED PAGE with the parameter named in
13
+ * `ignored_filters` (bd startsim-768w.38.39) — not a 400 and not an empty list.
14
+ * So "send the filter and trust the rows" is the dangerous shape here: the reply
15
+ * to a narrowing request that was ignored looks exactly like a narrow answer
16
+ * with a lot of rows in it. start-simpli#154 set the precedent — read the
17
+ * server's own `applied_filters` and do not use an unconfirmed result — and the
18
+ * fakes below behave the way the wire behaves, ignoring what they do not support
19
+ * and answering as wide as they were asked.
20
+ */
21
+ import { describe, expect, it } from 'vitest';
22
+
23
+ import { createCorpusApi } from '../corpus/client';
24
+ import type { CorpusHttp } from '../corpus/client';
25
+ import { startExtractionRun } from '../corpus/runs';
26
+ import {
27
+ DEFAULT_RUN_KEY,
28
+ DEFAULT_TYPE_KEYS,
29
+ MAX_ID_LIST,
30
+ extractionRunToEntity,
31
+ } from '../corpus/entities';
32
+ import type { TenantEntity, TenantRelationship } from '../corpus/entities';
33
+ import {
34
+ createFixtureCorpusApi,
35
+ createFixtureCorpusHttp,
36
+ createFixtureExtractor,
37
+ FIXTURE_ORG,
38
+ FIXTURE_SOURCE_LABELS,
39
+ } from '../corpus/fixtures';
40
+ import type { ExtractionRun, ExtractionRunStore } from '../corpus/types';
41
+
42
+ const SCOPE = { orgId: FIXTURE_ORG };
43
+
44
+ function apiOver(overrides: Parameters<typeof createFixtureCorpusHttp>[0] = {}) {
45
+ const { http, requests } = createFixtureCorpusHttp(overrides);
46
+ const api = createCorpusApi({ http, scope: SCOPE, sourceLabels: FIXTURE_SOURCE_LABELS });
47
+ return { api, requests };
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // bd startsim-o0o6o.3 — the sweep record, past the fifth row
52
+ // ---------------------------------------------------------------------------
53
+
54
+ function run(id: string): ExtractionRun {
55
+ return {
56
+ runId: id,
57
+ scope: SCOPE,
58
+ status: 'succeeded',
59
+ cursor: 4,
60
+ totalDocuments: 4,
61
+ totals: {
62
+ documentsProcessed: 4, fromRules: 2, fromModel: 1, ungrounded: 0, unconfirmed: 0,
63
+ created: 3, merged: 0, resolved: 0, droppedOutOfScope: 0, modelCalls: 4, modelFailures: 0,
64
+ },
65
+ processedIds: [],
66
+ modelIncompleteIds: [],
67
+ needsReview: [],
68
+ provider: { provider: 'fixture', model: 'fixture-rules', live: false },
69
+ startedAt: '2026-08-01T10:00:00Z',
70
+ updatedAt: '2026-08-01T10:06:00Z',
71
+ finishedAt: '2026-08-01T10:06:00Z',
72
+ leaseOwner: null,
73
+ leaseUntil: null,
74
+ error: null,
75
+ lastModelFailure: null,
76
+ history: [],
77
+ };
78
+ }
79
+
80
+ /** The singleton row, exactly as `writeRun` lands it. */
81
+ function singleton(): TenantEntity {
82
+ return {
83
+ id: 'run-singleton',
84
+ ...extractionRunToEntity(run('sweep-1'), DEFAULT_TYPE_KEYS.extractionRun, DEFAULT_RUN_KEY),
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Rows that are NOT the singleton, and which is which is not a hypothetical.
90
+ *
91
+ * A second workspace key, a renamed `runKey`, a partial migration or a
92
+ * hand-written row all put `extraction_run` rows next to it, and the tenant
93
+ * orders entities `-created_at` — so the singleton, which is the OLDEST because
94
+ * it was written first and is rewritten in place, sorts LAST. Newest first is
95
+ * the order these arrive in.
96
+ */
97
+ function noise(count: number): TenantEntity[] {
98
+ return Array.from({ length: count }, (_, index) => ({
99
+ id: `run-noise-${index}`,
100
+ entity_type: DEFAULT_TYPE_KEYS.extractionRun,
101
+ external_id: `corpus-extraction-sweep-other-${index}`,
102
+ name: `Extraction sweep other-${index}`,
103
+ data: { run: JSON.stringify(run(`other-${index}`)), _origin: 'tenant-extraction' },
104
+ }));
105
+ }
106
+
107
+ describe('readRun tells "nothing has ever run" apart from "I could not tell"', () => {
108
+ it('finds the singleton when it is NOT among the newest five rows', async () => {
109
+ // Six rows, singleton last: the exact corpus at which `page_size: 5` starts
110
+ // answering `null` — which every caller reads as "no sweep has ever run" and
111
+ // `startExtractionRun` acts on by starting a duplicate sweep.
112
+ const { api } = apiOver({ entities: [...noise(5), singleton()], relationships: [] });
113
+
114
+ const read = await api.readRun();
115
+
116
+ expect(read?.runId).toBe('sweep-1');
117
+ });
118
+
119
+ it('THROWS rather than reporting "never run" when the row could be past the cap', async () => {
120
+ // Past the server's own `max_page_size`, one read genuinely cannot see every
121
+ // row — and the honest answer to "has a sweep run here" is then neither the
122
+ // run nor `null`. `null` is a FACT in this contract; a truncated page is not
123
+ // evidence for it.
124
+ const { api } = apiOver({
125
+ entities: [...noise(MAX_ID_LIST + 20), singleton()],
126
+ relationships: [],
127
+ });
128
+
129
+ await expect(api.readRun()).rejects.toThrow(/could not/i);
130
+ });
131
+
132
+ it('still answers null for a workspace where nothing HAS run', async () => {
133
+ // The fact this whole contract exists to preserve: absence is reportable.
134
+ const { api } = apiOver({ entities: [], relationships: [] });
135
+
136
+ expect(await api.readRun()).toBeNull();
137
+ });
138
+
139
+ it('matches the singleton key in EITHER spelling the wire delivers it in', async () => {
140
+ // `@startsimpli/api`'s fetch wrapper transforms every response key
141
+ // snake_case -> camelCase, so a browser-side read of this row carries
142
+ // `externalId` and never `external_id`. This lookup read one spelling, so in
143
+ // the browser it matched nothing at ANY page size and reported "never run"
144
+ // over a workspace with a sweep in it — the same seam, and the same lie, as
145
+ // reading one spelling of `applied_filters`.
146
+ const { external_id: key, ...rest } = singleton();
147
+ const { api } = apiOver({
148
+ entities: [{ ...rest, externalId: key }],
149
+ relationships: [],
150
+ });
151
+
152
+ expect((await api.readRun())?.runId).toBe('sweep-1');
153
+ });
154
+
155
+ it('asks for the widest page the server will serve, not five rows', async () => {
156
+ const { api, requests } = apiOver({ entities: [singleton()], relationships: [] });
157
+ await api.readRun();
158
+
159
+ const read = requests.find(
160
+ (request) => request.params.type === DEFAULT_TYPE_KEYS.extractionRun,
161
+ );
162
+ expect(read?.params.page_size).toBe(MAX_ID_LIST);
163
+ });
164
+ });
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // bd startsim-o0o6o.2 — the edges of ONE PAGE, not the whole edge table
168
+ // ---------------------------------------------------------------------------
169
+
170
+ const PARTY: TenantEntity = {
171
+ id: 'party_dana', entity_type: 'party', external_id: 'granola:dana@example.com',
172
+ name: 'Dana Reed',
173
+ data: { display_name: 'Dana Reed', email: 'dana@example.com', source: 'granola' },
174
+ };
175
+
176
+ function document(id: string): TenantEntity {
177
+ return {
178
+ id, entity_type: 'source_document', external_id: `granola:${id}`, name: `Record ${id}`,
179
+ data: { source: 'granola', title: `Record ${id}`, occurred_at: '2026-08-01T09:00:00Z' },
180
+ };
181
+ }
182
+
183
+ describe('the participant join reads the edges of a PAGE, not the edge table', () => {
184
+ it('names the page’s ids in entity__in instead of asking for everything', async () => {
185
+ const { api, requests } = apiOver();
186
+ await api.listDocuments({ page: 1, pageSize: 3 });
187
+
188
+ const edgeReads = requests.filter((request) => request.path.includes('relationships'));
189
+ expect(edgeReads.length).toBeGreaterThan(0);
190
+ // THE ASSERTION THIS FILE EXISTS FOR. `readAll(relationships, {})` sent no
191
+ // parameters at all and walked up to twenty pages of every edge in the
192
+ // workspace, on a comment claiming the endpoint had no filters.
193
+ for (const read of edgeReads) {
194
+ const named = String(read.params.entity__in ?? '').split(',').filter(Boolean);
195
+ expect(named.length).toBeGreaterThan(0);
196
+ expect(named.length).toBeLessThanOrEqual(MAX_ID_LIST);
197
+ }
198
+ expect(String(edgeReads[0].params.entity__in).split(',')).toHaveLength(3);
199
+ });
200
+
201
+ it('still resolves the people on the page it narrowed to', async () => {
202
+ const { api } = apiOver({
203
+ entities: [document('doc-1'), PARTY],
204
+ relationships: [{ id: 'e1', rel_type: 'authored_by', source: 'doc-1', target: 'party_dana' }],
205
+ });
206
+
207
+ const page = await api.listDocuments({ page: 1 });
208
+
209
+ expect(page.results[0].participants.map((party) => party.name)).toEqual(['Dana Reed']);
210
+ });
211
+
212
+ it('chunks a page wider than the server’s id bound rather than being refused', async () => {
213
+ // A fork whose `max_page_size` is larger than `MAX_ID_LIST` hands back more
214
+ // ids than one `entity__in` may carry, and `_uuid_list` answers a longer
215
+ // list with a 400 — so the ids are chunked. Truncating them instead would
216
+ // drop the people off the tail of the page silently, which is the failure
217
+ // this whole change is undoing.
218
+ const documents = Array.from({ length: MAX_ID_LIST + 40 }, (_, index) =>
219
+ document(`doc-${index}`),
220
+ );
221
+ const last = documents[documents.length - 1];
222
+ const { api, requests } = apiOver({
223
+ pageSize: documents.length,
224
+ entities: [...documents, PARTY],
225
+ relationships: [
226
+ { id: 'e-last', rel_type: 'involves', source: String(last.id), target: 'party_dana' },
227
+ ],
228
+ });
229
+
230
+ const page = await api.listDocuments({ page: 1 });
231
+
232
+ const edgeReads = requests.filter((request) => request.path.includes('relationships'));
233
+ expect(edgeReads.length).toBeGreaterThan(1);
234
+ for (const read of edgeReads) {
235
+ expect(String(read.params.entity__in).split(',').length).toBeLessThanOrEqual(MAX_ID_LIST);
236
+ }
237
+ const tail = page.results.find((record) => record.id === String(last.id));
238
+ expect(tail?.participants.map((party) => party.name)).toEqual(['Dana Reed']);
239
+ });
240
+ });
241
+
242
+ /**
243
+ * A fork that has NOT shipped `RelationshipQuery`.
244
+ *
245
+ * It does what the tenant documents itself as doing with a parameter it does not
246
+ * know: names it in `ignored_filters` and answers with the page exactly as wide
247
+ * as it was. A fake that returned `[]` here would let the client pass this suite
248
+ * while reading the whole edge table in production, which is the trap this test
249
+ * is written to spring.
250
+ */
251
+ function unfilteredEdgeHttp(edges: TenantRelationship[], rows: TenantEntity[]) {
252
+ const requests: Array<{ path: string; params: Record<string, unknown> }> = [];
253
+ const http: CorpusHttp = {
254
+ async get<T>(path: string, init?: { params?: Record<string, unknown> }): Promise<T> {
255
+ const params = init?.params ?? {};
256
+ requests.push({ path, params });
257
+ if (path.includes('relationships')) {
258
+ return {
259
+ count: edges.length, next: null, previous: null, results: edges,
260
+ applied_filters: [],
261
+ ignored_filters: Object.keys(params).filter(
262
+ (key) => key !== 'page' && key !== 'page_size',
263
+ ),
264
+ } as T;
265
+ }
266
+ if (path.includes('entities')) {
267
+ const type = params.type ? String(params.type) : '';
268
+ const matching = type ? rows.filter((row) => row.entity_type === type) : rows;
269
+ return {
270
+ count: matching.length, next: null, previous: null, results: matching,
271
+ applied_filters: [],
272
+ ignored_filters: [],
273
+ } as T;
274
+ }
275
+ throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
276
+ },
277
+ };
278
+ return { http, requests };
279
+ }
280
+
281
+ describe('an unconfirmed narrowing is not used', () => {
282
+ it('renders no participants rather than joining an unnarrowed page of edges', async () => {
283
+ // start-simpli#154's precedent, on the read that matters most: the server
284
+ // reported it did NOT apply `entity__in`, so the rows it sent are an
285
+ // arbitrary page of the whole edge table and joining them would put the
286
+ // wrong people — or a partial list of the right ones — on the record. The
287
+ // Library degrades to what it showed before the join existed.
288
+ const { http, requests } = unfilteredEdgeHttp(
289
+ [{ id: 'e1', rel_type: 'authored_by', source: 'doc-1', target: 'party_dana' }],
290
+ [document('doc-1'), PARTY],
291
+ );
292
+ const api = createCorpusApi({ http, scope: SCOPE, sourceLabels: FIXTURE_SOURCE_LABELS });
293
+
294
+ const page = await api.listDocuments({ page: 1 });
295
+
296
+ expect(page.results).toHaveLength(1);
297
+ expect(page.results[0].participants).toEqual([]);
298
+ // And it does not go on to read the party roster for a join it will not do.
299
+ expect(requests.filter((request) => request.params.type === 'party')).toHaveLength(0);
300
+ });
301
+ });
302
+
303
+ describe('a store that could not answer never becomes "no sweep has ever run"', () => {
304
+ it('refuses to start a second sweep over a cursor it was unable to read', async () => {
305
+ // THE REASON THE THIRD ANSWER HAD TO REACH THIS CALLER. `startExtractionRun`
306
+ // swallowed every `readRun` failure into `null` and `null` means "start a
307
+ // new sweep" one branch later, so an undetermined read put a SECOND driver
308
+ // on a live cursor — and two model calls over one document produce two
309
+ // differently-worded items dedupe cannot see are the same, which is how a
310
+ // live re-run once turned 82 commitments into 98.
311
+ let writes = 0;
312
+ const runs: ExtractionRunStore = {
313
+ async readRun() {
314
+ throw Object.assign(
315
+ new Error('Could not determine whether a sweep has run in this workspace.'),
316
+ { status: 409 },
317
+ );
318
+ },
319
+ async writeRun(run) {
320
+ writes += 1;
321
+ return run;
322
+ },
323
+ };
324
+
325
+ await expect(
326
+ startExtractionRun(
327
+ { scope: SCOPE },
328
+ {
329
+ corpus: createFixtureCorpusApi({ scope: SCOPE }),
330
+ extractor: createFixtureExtractor(),
331
+ runs,
332
+ },
333
+ ),
334
+ ).rejects.toThrow(/could not determine/i);
335
+ expect(writes).toBe(0);
336
+ });
337
+ });
@@ -191,8 +191,10 @@ describe('both storage shapes, because both are real', () => {
191
191
  });
192
192
  });
193
193
 
194
- describe('the graph read is bounded, cached and fails soft', () => {
195
- it('costs ONE graph read across a list, a detail and a second page', async () => {
194
+ describe('the graph read is narrowed, cached and fails soft', () => {
195
+ it('costs ONE edge read across a list, a detail and a second page', async () => {
196
+ // The records are remembered per row, so re-listing them and then opening
197
+ // one of them asks the server nothing further (bd startsim-o0o6o.2).
196
198
  const { api, requests } = apiOver();
197
199
  await api.listDocuments({ page: 1 });
198
200
  await api.listDocuments({ page: 1 });
@@ -220,17 +222,26 @@ describe('the graph read is bounded, cached and fails soft', () => {
220
222
  expect(page.results.every((document) => document.participants.length === 0)).toBe(true);
221
223
  });
222
224
 
223
- it('walks every page of the graph, since the tenant ignores page_size', async () => {
224
- // Two rows a page: the edges for the last document are only reachable by
225
- // paging, which is exactly what the real PAGE_SIZE=50 does at scale.
225
+ it('narrows the edge read to the ids of the page it is rendering', async () => {
226
+ // THIS TEST USED TO ASSERT THE OPPOSITE, and its title said why: "walks
227
+ // every page of the graph, since the tenant ignores page_size". Neither half
228
+ // survived contact with the deployed server. `page_size` is honoured
229
+ // (`TenantPageNumberPagination`, bd startsim-768w.38.12) and the edge table
230
+ // is filterable (`RelationshipQuery`, bd startsim-te3r) — so a page deep in
231
+ // the corpus no longer reaches its people by reading every edge in the
232
+ // workspace and joining in the browser. It ASKS for the edges of the two
233
+ // rows it is rendering, which is the read that scales with the page instead
234
+ // of with the corpus (bd startsim-o0o6o.2).
226
235
  const { api, requests } = apiOver({ pageSize: 2 });
227
- // `pageSize: 100` is ignored by the server, exactly as the tenant ignores
228
- // it — so the Dropbox row is on page THREE however wide the caller asked.
229
- const page = await api.listDocuments({ page: 3, pageSize: 100 });
236
+ const page = await api.listDocuments({ page: 3 });
230
237
  const dropbox = page.results.find((document) => document.id === 'dbx_9812f0');
231
238
 
232
239
  expect(dropbox?.participants.map((party) => party.name)).toEqual(['Acme Robotics', 'Dana Reed']);
233
- expect(requests.filter((request) => request.path.includes('relationships')).length).toBeGreaterThan(1);
240
+ const edgeReads = requests.filter((request) => request.path.includes('relationships'));
241
+ expect(edgeReads).toHaveLength(1);
242
+ expect(String(edgeReads[0].params.entity__in).split(',')).toEqual(
243
+ page.results.map((document) => document.id),
244
+ );
234
245
  });
235
246
  });
236
247