@startsimpli/integrations 0.2.31 → 0.2.33

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.31",
3
+ "version": "0.2.33",
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,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
+ });
@@ -241,3 +241,228 @@ describe('the Library strip, rendered', () => {
241
241
  expect(screen.queryByText(/Dropbox/)).not.toBeInTheDocument();
242
242
  });
243
243
  });
244
+
245
+ /**
246
+ * THE COUNT ON THE CARD IS A CORPUS FACT, AND IT WAS NEVER READ
247
+ * (bd startsim-jevtu RED, first filed as startsim-o0o6o.1).
248
+ *
249
+ * REPORTED ON THE LIVE TANDEM TENANT, 2026-08-26: Dropbox, Fathom, Granola and
250
+ * n8n each read "0 records" while the list directly beneath the strip read
251
+ * "208 records · page 1 of 11". `listSources()` computed the number as
252
+ * `connection.document_count ?? connection.documentCount ?? 0` and NO
253
+ * connections endpoint sends either spelling — `SubtreeConnectionsView`
254
+ * serialises id, connector_key, connector_name, name, status, account_label,
255
+ * the three timestamps, consecutive_failures and check_error, and no count. So
256
+ * every card has read `0` for every foundry since the strip shipped.
257
+ *
258
+ * WHY THE NUMBER COMES FROM THE TENANT CORPUS AND NOT FROM CENTRAL.
259
+ *
260
+ * 1. Central does not have the rows. The corpus lives in the fork; central
261
+ * delivers INTO it (`corpus_delivery.py`) and keeps no copy. A count
262
+ * annotated onto the connection would be a count of records central
263
+ * believes it shipped — a different number from the one in the list under
264
+ * the strip the moment an upsert collapses two records, a retention rule
265
+ * trims, or a delivery half-lands. A strip that disagrees with the list
266
+ * beneath it is the exact fault class this bead is about; a delivery
267
+ * counter would re-open it in a new spelling.
268
+ *
269
+ * 2. Every other fact on the card already comes from central's connection
270
+ * row — status, the three timestamps, the failure streak. The corpus count
271
+ * is the ONLY corpus-side fact on a strip that sits on top of the corpus,
272
+ * and that is what makes it worth its round trip: "central says it synced
273
+ * an hour ago, the corpus holds nothing from it" is the delivery-hop
274
+ * failure, and no connection field can say it.
275
+ *
276
+ * WHY THIS IS NOT A SCAN. The tenant's own `/api/v1/capabilities/` declares
277
+ * `"counts": "page-count"` and says in as many words that a focus card's
278
+ * "12 of 40 done" is "two `page_size=1` reads off the paginator's own `count` —
279
+ * said here so a client does not go looking for a group_by endpoint that
280
+ * deliberately does not exist". One narrowed request per source, asking for one
281
+ * row, reading the envelope's `count`: one SQL COUNT with the filter pushed
282
+ * down, which is the sanctioned idiom on this platform and not eleven pages.
283
+ *
284
+ * WHY IT IS UNBLOCKED. bd startsim-768w.38.12 shipped `?source=` and verified it
285
+ * on this very tenant ("source=granola 16 / stripe 0"). The vocabulary lines up
286
+ * by construction, not by luck: central serialises `connector_key` as
287
+ * `connection.connector.key`, `corpus_delivery` stamps `source_key =
288
+ * connector_cls.key` onto every projected record, and the catalog reconciler
289
+ * creates each `DestinationProvider` row AT the connector class's own key. The
290
+ * canonical mapping says it outright — "`source` is the connector key, so
291
+ * 'everything from Granola' is one filter".
292
+ *
293
+ * AND WHY `null` HAS TO EXIST. `0` today means "we never asked". After this it
294
+ * must mean "we asked and the answer is none" — otherwise the fix replaces a
295
+ * lie with a lie that happens to be right some of the time. A build that cannot
296
+ * narrow by source returns the WHOLE corpus for every card, which would print
297
+ * "208 records" four times over; the server's own `applied_filters` is what
298
+ * tells the two apart, and a count that cannot be trusted is not printed.
299
+ */
300
+
301
+ /** Per-source totals as a tenant that really does honour `?source=` reports them. */
302
+ function corpusOver(
303
+ rows: Array<Record<string, unknown>>,
304
+ counts: Record<string, number>,
305
+ options: { reports?: 'snake' | 'camel' | 'none' } = {},
306
+ ): Recorder {
307
+ const reports = options.reports ?? 'snake';
308
+ const whole = Object.values(counts).reduce((sum, n) => sum + n, 0);
309
+ const calls: Array<{ path: string; params?: Record<string, unknown> }> = [];
310
+ return {
311
+ calls,
312
+ http: {
313
+ async get<T>(path: string, init?: { params?: Record<string, unknown> }): Promise<T> {
314
+ const params = init?.params ?? {};
315
+ calls.push({ path, params });
316
+ if (path.includes('connections')) {
317
+ return { count: rows.length, next: null, previous: null, results: rows } as T;
318
+ }
319
+ const source = params.source === undefined ? '' : String(params.source);
320
+ // A build with no `?source=` narrows NOTHING and hands back the whole
321
+ // corpus — the failure mode that would print 208 on every card.
322
+ const narrowed = reports !== 'none' && Boolean(source);
323
+ const count = narrowed ? (counts[source] ?? 0) : whole;
324
+ const applied = narrowed ? ['type', 'source'] : ['type'];
325
+ return {
326
+ count,
327
+ next: null,
328
+ previous: null,
329
+ // Faithful to the wire: a `page_size=1` read over a non-empty match
330
+ // returns a row. A count read must not need it.
331
+ results: count > 0 ? [{ id: 'row', entity_type: 'source_document', data: {} }] : [],
332
+ ...(reports === 'snake' ? { applied_filters: applied } : {}),
333
+ ...(reports === 'camel' ? { appliedFilters: applied } : {}),
334
+ } as T;
335
+ },
336
+ },
337
+ };
338
+ }
339
+
340
+ /** A second shared connection, one that has delivered nothing — as tandem has. */
341
+ const SHARED_N8N = {
342
+ id: 'shared-n8n',
343
+ connector_key: 'n8n',
344
+ connector_name: 'n8n',
345
+ status: 'connected',
346
+ check_error: '',
347
+ last_synced_at: AN_HOUR_AGO,
348
+ last_attempt_at: AN_HOUR_AGO,
349
+ last_checked_at: AN_HOUR_AGO,
350
+ consecutive_failures: 0,
351
+ };
352
+
353
+ const TANDEM_COUNTS = { granola: 208, n8n: 0 };
354
+
355
+ function countedSources(options: { reports?: 'snake' | 'camel' | 'none' } = {}) {
356
+ const { http } = corpusOver([SHARED_GRANOLA, SHARED_N8N], TANDEM_COUNTS, options);
357
+ return createCorpusApi({ http, scope: SCOPE }).listSources();
358
+ }
359
+
360
+ describe('listSources — the record count is counted, not read off the connection', () => {
361
+ it('reports what each source actually put in the corpus', async () => {
362
+ const sources = await countedSources();
363
+ const byKey = Object.fromEntries(sources.map((source) => [source.source, source.documentCount]));
364
+
365
+ // The whole bug in one assertion: Granola holds 208 records and the card
366
+ // said 0, because it was reading a field central has never sent.
367
+ expect(byKey).toEqual({ granola: 208, n8n: 0 });
368
+ });
369
+
370
+ it('counts DOCUMENTS, so the strip agrees with the list beneath it', async () => {
371
+ // A count over every entity type would sweep in parties, money events and
372
+ // commitments and overshoot the list it sits above. Same type key, same
373
+ // read gate, same server — the two agree by construction or not at all.
374
+ const recorder = corpusOver([SHARED_GRANOLA, SHARED_N8N], TANDEM_COUNTS);
375
+ await createCorpusApi({ http: recorder.http, scope: SCOPE }).listSources();
376
+
377
+ const reads = recorder.calls.filter((call) => call.path.includes('entities'));
378
+ expect(reads.length).toBeGreaterThan(0);
379
+ for (const call of reads) expect(call.params?.type).toBe('source_document');
380
+ });
381
+
382
+ it('asks for ONE row per source and never pages the corpus', async () => {
383
+ // "Eleven pages per card" is the same wrong answer with worse latency. The
384
+ // count comes off the paginator's own envelope, which costs one row.
385
+ const recorder = corpusOver([SHARED_GRANOLA, SHARED_N8N], TANDEM_COUNTS);
386
+ await createCorpusApi({ http: recorder.http, scope: SCOPE }).listSources();
387
+
388
+ const reads = recorder.calls.filter((call) => call.path.includes('entities'));
389
+ expect(reads).toHaveLength(2);
390
+ expect(reads.map((call) => call.params?.source).sort()).toEqual(['granola', 'n8n']);
391
+ for (const call of reads) expect(call.params?.page_size).toBe(1);
392
+ });
393
+
394
+ it('counts a vendor once when two connections name it', async () => {
395
+ // A card is per CONNECTION, a count is per SOURCE. Central's page unions the
396
+ // foundry's shared connections with the member's own, so one vendor can
397
+ // arrive twice — and whose account it is does not narrow the corpus. Two
398
+ // reads for one answer, and both cards must report that answer rather than
399
+ // half of it each.
400
+ const MY_GRANOLA = { ...SHARED_GRANOLA, id: 'my-granola', owner: 'qosha@debugg.ai' };
401
+ const recorder = corpusOver([SHARED_GRANOLA, MY_GRANOLA, SHARED_N8N], TANDEM_COUNTS);
402
+ const sources = await createCorpusApi({ http: recorder.http, scope: SCOPE }).listSources();
403
+
404
+ const reads = recorder.calls.filter((call) => call.path.includes('entities'));
405
+ expect(reads.filter((call) => call.params?.source === 'granola')).toHaveLength(1);
406
+ expect(
407
+ sources.filter((source) => source.source === 'granola').map((s) => s.documentCount),
408
+ ).toEqual([208, 208]);
409
+ });
410
+
411
+ it('says 0 because the corpus answered 0, not because nobody asked', async () => {
412
+ const n8n = (await countedSources()).find((source) => source.source === 'n8n');
413
+
414
+ // n8n declares no corpus mapping at all (`corpus_delivery` returns early on
415
+ // it), so zero is the true answer — and it has to be distinguishable from
416
+ // the count nobody went and got.
417
+ expect(n8n?.documentCount).toBe(0);
418
+ });
419
+
420
+ it('reads the server’s report in either spelling', async () => {
421
+ // `@startsimpli/api` camelCases response keys in the browser and a plain
422
+ // server-side fetch does not. Reading one spelling made the deployed
423
+ // Tracker report the server was not filtering while it verifiably was; the
424
+ // same seam decides here whether a real count is thrown away.
425
+ const sources = await countedSources({ reports: 'camel' });
426
+
427
+ expect(sources.find((source) => source.source === 'granola')?.documentCount).toBe(208);
428
+ });
429
+
430
+ it('refuses to print the whole corpus when the server could not narrow by source', async () => {
431
+ // The regression this fix could otherwise introduce: an older fork ignores
432
+ // `?source=`, hands back the total, and every card reads "208 records". The
433
+ // server says what it applied; a count it did not narrow is not a count.
434
+ const sources = await countedSources({ reports: 'none' });
435
+
436
+ expect(sources.map((source) => source.documentCount)).toEqual([null, null]);
437
+ });
438
+ });
439
+
440
+ describe('the Library strip, counting', () => {
441
+ const strip = async () =>
442
+ within(await screen.findByRole('region', { name: 'Source freshness' }));
443
+
444
+ it('prints the count the corpus actually holds', async () => {
445
+ const { http } = corpusOver([SHARED_GRANOLA, SHARED_N8N], TANDEM_COUNTS);
446
+ render(<LibraryPage api={createCorpusApi({ http, scope: SCOPE })} scope={SCOPE} />);
447
+
448
+ expect((await strip()).getByText(/208 records/)).toBeInTheDocument();
449
+ });
450
+
451
+ it('says a source has delivered nothing, in words', async () => {
452
+ const { http } = corpusOver([SHARED_GRANOLA, SHARED_N8N], TANDEM_COUNTS);
453
+ render(<LibraryPage api={createCorpusApi({ http, scope: SCOPE })} scope={SCOPE} />);
454
+
455
+ expect((await strip()).getByText(/No records yet/)).toBeInTheDocument();
456
+ });
457
+
458
+ it('prints no number at all when the count is unknown', async () => {
459
+ const { http } = corpusOver([SHARED_GRANOLA, SHARED_N8N], TANDEM_COUNTS, { reports: 'none' });
460
+ render(<LibraryPage api={createCorpusApi({ http, scope: SCOPE })} scope={SCOPE} />);
461
+
462
+ const shown = await strip();
463
+ // The freshness half of the card is unaffected — a count it could not get
464
+ // costs the reader the count, not the sentence.
465
+ expect(shown.getAllByText(/synced/).length).toBeGreaterThan(0);
466
+ expect(shown.queryByText(/record/)).not.toBeInTheDocument();
467
+ });
468
+ });
@@ -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
 
@@ -519,6 +519,31 @@ const FRESHNESS_WORDS: Record<SourceFreshness['status'], string> = {
519
519
  never: 'Nothing yet',
520
520
  };
521
521
 
522
+ /**
523
+ * The count clause of a source card, or nothing at all (bd startsim-jevtu).
524
+ *
525
+ * THREE STATES, NOT TWO. `null` is "this workspace's API could not narrow the
526
+ * corpus by source, so the only number available describes every source at
527
+ * once" — printing it would put the same total on every card, which is the
528
+ * failure this replaced wearing a bigger number. Saying nothing is the honest
529
+ * option, and it costs the reader the count rather than the card.
530
+ *
531
+ * `0` is spelled out in words rather than left as a bare digit because "0
532
+ * records" is exactly what the broken version printed for years; a source that
533
+ * has genuinely delivered nothing deserves a sentence that could not be
534
+ * produced by not asking.
535
+ *
536
+ * NOTE THE SCOPE. This is the source's WHOLE contribution, unfiltered — the
537
+ * strip describes sources, while the list beneath it answers the current query.
538
+ * A card reading "208 records" above a list reading "3 records" under an active
539
+ * search is the two questions being different, not a disagreement.
540
+ */
541
+ function countClause(count: SourceFreshness['documentCount']): string {
542
+ if (count === null || count === undefined) return '';
543
+ if (count === 0) return 'No records yet · ';
544
+ return `${count} record${count === 1 ? '' : 's'} · `;
545
+ }
546
+
522
547
  function FreshnessStrip({
523
548
  sources, classNames: cn, now,
524
549
  }: { sources: SourceFreshness[]; classNames: Record<LibrarySlot, string>; now: number }) {
@@ -537,8 +562,7 @@ function FreshnessStrip({
537
562
  </span>
538
563
  </div>
539
564
  <p className={cn.muted}>
540
- {source.documentCount} record{source.documentCount === 1 ? '' : 's'} · synced{' '}
541
- {relative(source.lastSyncedAt, now)}
565
+ {countClause(source.documentCount)}synced {relative(source.lastSyncedAt, now)}
542
566
  </p>
543
567
  {/* A source that is failing says so HERE, next to the corpus it is
544
568
  supposed to be filling — not on a settings page nobody opens. */}