@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.
|
|
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}
|
|
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. */}
|
package/src/corpus/client.ts
CHANGED
|
@@ -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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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`
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
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
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
package/src/corpus/fixtures.ts
CHANGED
|
@@ -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
|
|
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,
|
package/src/corpus/types.ts
CHANGED
|
@@ -172,8 +172,22 @@ export interface MoneyEvent {
|
|
|
172
172
|
export interface SourceFreshness {
|
|
173
173
|
source: SourceKey;
|
|
174
174
|
label: string;
|
|
175
|
-
/**
|
|
176
|
-
|
|
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
|