@startsimpli/integrations 0.2.31 → 0.2.32

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.32",
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",
@@ -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
+ });
@@ -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. */}
@@ -11,11 +11,16 @@
11
11
  * there must not be: it produces a list whose count disagrees with its rows and
12
12
  * that stops working at exactly the corpus size where it starts to matter.
13
13
  *
14
- * WHAT THE BACKEND HONOURS TODAY: `tenant-starter`'s `EntityViewSet` filters on
15
- * `?type=` and nothing else — no `search`, no `source`, no date window (see
16
- * `apps/api/views.py`). So `supportedFilters` defaults to `[]` and the composer
17
- * says so on screen rather than pretending. Pass the real list once the tenant
18
- * API gains them; the request already sends every parameter.
14
+ * WHAT THE BACKEND HONOURS IS THE BACKEND'S TO SAY, NOT THIS FILE'S. It once
15
+ * said here that `tenant-starter`'s `EntityViewSet` filters on `?type=` and
16
+ * nothing else; bd startsim-768w.38.12 shipped `search`, `source`, `party`, the
17
+ * date window and `order`, and the comment stayed behind — which is exactly why
18
+ * a hardcoded list is the wrong mechanism. Every list response carries the
19
+ * server's own `applied_filters`/`ignored_filters`, `/api/v1/capabilities/`
20
+ * describes the deployed build, and both are read in preference to anything
21
+ * declared client-side. `supportedFilters` remains only as the fallback for a
22
+ * fork too old to report, and a deployment that passes a stale one is corrected
23
+ * by the server rather than believed.
19
24
  *
20
25
  * PEOPLE ARE A SECOND READ, NOT A SECOND COPY (bd startsim-768w.38.22). The
21
26
  * corpus delivery writes participants as typed `authored_by` / `involves` edges
@@ -430,6 +435,42 @@ export function createCorpusApi(
430
435
  };
431
436
  }
432
437
 
438
+ /**
439
+ * How many DOCUMENTS one source has put in the corpus, or `null` when the
440
+ * server did not confirm it narrowed by source.
441
+ *
442
+ * ONE ROW, AND THE ENVELOPE'S OWN `count`. `page_size=1` is not a sample: the
443
+ * DRF paginator counts the filtered queryset in SQL and reports it beside the
444
+ * page, so this is one COUNT with `?source=` pushed down, not eleven pages
445
+ * added up. `types.document` is passed for the same reason the list below the
446
+ * strip passes it — a count over every entity type would sweep in parties,
447
+ * money events and commitments and stop agreeing with the list it sits above.
448
+ *
449
+ * THE GUARD IS THE SERVER'S OWN REPORT, in both spellings for the reason
450
+ * `ServerFilterReport` documents. A fork that predates bd startsim-768w.38.12
451
+ * accepts `?source=` and ignores it, so the honest-looking `count` that comes
452
+ * back is the whole corpus — the same number on every card. `applied_filters`
453
+ * naming `source` is what separates "none from this source" from "I could not
454
+ * ask", and only the first of those is a number worth printing.
455
+ */
456
+ async function countDocumentsFrom(source: string): Promise<number | null> {
457
+ if (!source || source === 'unknown') return null;
458
+ try {
459
+ const response = await http.get<DrfPage<TenantEntity> & ServerFilterReport>(entities, {
460
+ params: { type: types.document, source, page_size: 1 },
461
+ });
462
+ const applied = response.applied_filters ?? response.appliedFilters;
463
+ if (!applied?.includes('source')) return null;
464
+ const count = Number(response.count);
465
+ return Number.isFinite(count) ? count : null;
466
+ } catch {
467
+ // A count is an ornament on a card whose point is freshness. Losing it
468
+ // costs the reader a number; failing the whole strip would cost the four
469
+ // facts the card exists for.
470
+ return null;
471
+ }
472
+ }
473
+
433
474
  /** Every page of a DRF list, up to the cap. Stops on the first failure. */
434
475
  async function readAll<T>(path: string, extra: Record<string, unknown>): Promise<T[]> {
435
476
  const rows: T[] = [];
@@ -670,26 +711,72 @@ export function createCorpusApi(
670
711
  * exactly the lie bd startsim-1cow removed. Rows are read from one endpoint,
671
712
  * as handed.
672
713
  *
673
- * `documentCount` is whatever the connections payload says and is therefore
674
- * `0` today: no connections endpoint sends `document_count`, and a per-source
675
- * count from the corpus needs the `?source=` filter bd startsim-768w.38.12
676
- * is still open on. Tracked as bd startsim-o0o6o.1.
714
+ * `documentCount` IS THE ONE CORPUS-SIDE FACT ON THIS CARD, and it is
715
+ * counted here rather than read off the connection (bd startsim-jevtu, first
716
+ * filed as startsim-o0o6o.1). It used to be
717
+ * `connection.document_count ?? connection.documentCount ?? 0` over a
718
+ * payload that has never carried either spelling, so every card on every
719
+ * foundry read "0 records" — on tandem, four of them, directly above a list
720
+ * reading "208 records".
721
+ *
722
+ * CENTRAL CANNOT ANSWER IT. The corpus lives in the fork; central delivers
723
+ * INTO it and keeps no copy, so a count annotated onto the connection would
724
+ * be a count of what central believes it shipped — a different number from
725
+ * the list under the strip as soon as an upsert collapses two records or a
726
+ * delivery half-lands. A strip that disagrees with its own list is the fault
727
+ * being fixed, not a fix for it. And the disagreement is the POINT: every
728
+ * other field on this card comes from central's connection row, so "central
729
+ * says it synced an hour ago, the corpus holds nothing from it" is the
730
+ * delivery-hop failure made visible, which no connection field can say.
731
+ *
732
+ * ONE COUNT PER SOURCE, NOT A SCAN. `page_size=1` and the paginator's own
733
+ * `count` — one SQL COUNT with `?source=` pushed down. That is the idiom the
734
+ * tenant's `/api/v1/capabilities/` declares (`"counts": "page-count"`, and
735
+ * "a focus card's '12 of 40 done' is two `page_size=1` reads … so a client
736
+ * does not go looking for a group_by endpoint that deliberately does not
737
+ * exist"). Paging the corpus per card would be the same wrong answer with
738
+ * worse latency.
739
+ *
740
+ * `null`, NEVER A GUESSED `0`. A fork that predates the `?source=` filter
741
+ * (bd startsim-768w.38.12) narrows nothing and hands back the WHOLE corpus,
742
+ * which would print the same total on every card. The server's own
743
+ * `applied_filters` is the only thing that tells a narrowed count from an
744
+ * unnarrowed one, so a count it did not confirm is not reported at all —
745
+ * and `0` is left free to mean what it says.
677
746
  */
678
747
  async listSources(): Promise<SourceFreshness[]> {
679
- const [connectionPage, documentPage] = await Promise.all([
680
- http
681
- .get<DrfPage<Record<string, unknown>>>(connections)
682
- .catch(() => EMPTY_PAGE as DrfPage<Record<string, unknown>>),
683
- http
684
- .get<DrfPage<TenantEntity>>(entities, { params: { type: types.document, page_size: 1 } })
685
- .catch(() => EMPTY_PAGE as DrfPage<TenantEntity>),
686
- ]);
748
+ const connectionPage = await http
749
+ .get<DrfPage<Record<string, unknown>>>(connections)
750
+ .catch(() => EMPTY_PAGE as DrfPage<Record<string, unknown>>);
687
751
 
688
- void documentPage; // total count is per-source below; kept for the empty-corpus case
752
+ const live = (connectionPage.results ?? []).filter(isALiveSource).map((connection) => ({
753
+ connection,
754
+ key: String(connection.connector_key ?? connection.connectorKey ?? 'unknown'),
755
+ }));
756
+ // ONE READ PER DISTINCT SOURCE KEY, in parallel — the cards are
757
+ // independent and a slow source must not hold up the rest of the strip.
758
+ //
759
+ // Distinct, because a card is per CONNECTION and a count is per SOURCE,
760
+ // and those are not the same thing. Central's page unions the foundry's
761
+ // shared connections with the member's own (bd startsim-768w.22.25.11),
762
+ // so a shared Granola and a personal Granola arrive as two rows naming one
763
+ // vendor. Whose account it is belongs on the card; it does not narrow the
764
+ // corpus, so counting it twice would be two identical queries for one
765
+ // answer. Both cards then report the same total, which is the true one:
766
+ // the corpus does not record which of two accounts a document came in
767
+ // through.
768
+ const pending = new Map<string, Promise<number | null>>();
769
+ for (const { key } of live) {
770
+ if (!pending.has(key)) pending.set(key, countDocumentsFrom(key));
771
+ }
772
+ const counts = new Map(
773
+ await Promise.all(
774
+ [...pending].map(async ([key, count]) => [key, await count] as const),
775
+ ),
776
+ );
689
777
 
690
778
  const now = Date.now();
691
- return (connectionPage.results ?? []).filter(isALiveSource).map((connection) => {
692
- const key = String(connection.connector_key ?? connection.connectorKey ?? 'unknown');
779
+ return live.map(({ connection, key }) => {
693
780
  // THREE FACTS, READ SEPARATELY (bd startsim-1cow). This was one `??`
694
781
  // chain ending in `last_checked_at`, and central's own view says of that
695
782
  // column that it "is a far more optimistic fact than either and is never
@@ -709,7 +796,7 @@ export function createCorpusApi(
709
796
  const lastCheckedAt = at(connection.last_checked_at ?? connection.lastCheckedAt);
710
797
  const failures =
711
798
  Number(connection.consecutive_failures ?? connection.consecutiveFailures ?? 0) || 0;
712
- const documentCount = Number(connection.document_count ?? connection.documentCount ?? 0);
799
+ const documentCount = counts.get(key) ?? null;
713
800
  const error = String(connection.check_error ?? connection.checkError ?? '');
714
801
  const status = connection.status ?? '';
715
802
 
@@ -869,7 +869,12 @@ export function createFixtureCorpusApi(options: FixtureCorpusOptions = {}): Corp
869
869
  * search since this was written (bd startsim-768w.38.12) and a retrieval
870
870
  * test must be written against the real semantics, but the suites that
871
871
  * predate it are still testing what they were written to test. `source` and
872
- * `party` are still accepted and ignored, exactly as `EntityViewSet` does;
872
+ * `party` are accepted and ignored HERE, and this fixture no longer claims
873
+ * that is what `EntityViewSet` does: 768w.38.12 shipped both, along with the
874
+ * `applied_filters` report that names what the server narrowed by. So this
875
+ * stands in for an OLDER fork — which is a useful thing to have a stand-in
876
+ * for, since that is the fork `listSources` refuses to print a count for
877
+ * (bd startsim-jevtu), but it is a lagging stub and not the contract;
873
878
  * • `page_size` is IGNORED (`PageNumberPagination` with no
874
879
  * `page_size_query_param`), so a caller asking for 200 gets `pageSize`;
875
880
  * • `/api/v1/relationships/` returns every edge in the org, unfiltered,
@@ -172,8 +172,22 @@ export interface MoneyEvent {
172
172
  export interface SourceFreshness {
173
173
  source: SourceKey;
174
174
  label: string;
175
- /** How many documents this source has contributed to the corpus. */
176
- documentCount: number;
175
+ /**
176
+ * How many documents this source has contributed to the corpus — counted OUT
177
+ * OF THE CORPUS, per source, and the only fact on a freshness card that is
178
+ * not central's connection bookkeeping (bd startsim-jevtu). That is what
179
+ * makes it worth its round trip: a source central reports as freshly synced
180
+ * that holds nothing here is the delivery hop failing, and no timestamp on
181
+ * the connection can say so.
182
+ *
183
+ * `null` means NOT COUNTED — the server did not confirm it narrowed by
184
+ * source, so the number it returned describes the whole corpus rather than
185
+ * this card. A screen renders nothing at all for `null`. `0` therefore means
186
+ * exactly what it says, which it did not before: the field was read off a
187
+ * connections payload that has never carried it, so every card everywhere
188
+ * reported `0` by accident.
189
+ */
190
+ documentCount: number | null;
177
191
  /**
178
192
  * THREE FACTS, AND CONFLATING ANY TWO OF THEM IS HOW A FRESHNESS STRIP LIES
179
193
  * (bd startsim-1cow). Central's `SubtreeConnectionsView` computes them