@startsimpli/integrations 0.2.30 → 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 +1 -1
- package/src/__tests__/connections.test.tsx +9 -3
- package/src/__tests__/corpus-source-strip.test.tsx +225 -0
- package/src/__tests__/member-connection-scope.test.tsx +314 -0
- package/src/api/connections.ts +43 -5
- package/src/components/CredentialSchemaForm.tsx +38 -1
- package/src/components/pages/ConnectionsPage.tsx +54 -6
- package/src/components/pages/LibraryPage.tsx +26 -2
- package/src/components/pages/types.ts +44 -3
- package/src/corpus/client.ts +108 -21
- package/src/corpus/fixtures.ts +6 -1
- package/src/corpus/types.ts +16 -2
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",
|
|
@@ -813,9 +813,15 @@ describe('ConnectionCard — a connected account has settings', () => {
|
|
|
813
813
|
* — "there is no id here to substitute, no slug to swap". A client that
|
|
814
814
|
* takes a slug re-introduces, in the types, the exact affordance that URL
|
|
815
815
|
* space was designed not to have.
|
|
816
|
-
* * NO PATCH. `MyConnectionDetailView` serves GET and DELETE
|
|
817
|
-
*
|
|
818
|
-
*
|
|
816
|
+
* * NO GENERAL PATCH. `MyConnectionDetailView` still serves GET and DELETE
|
|
817
|
+
* only, so a card's NAME and what it may DO stay the foundry's and the
|
|
818
|
+
* capability picker is informational with no Save beside it. The one write
|
|
819
|
+
* verb the member surface grew is `updateConnectionConfig` (bd
|
|
820
|
+
* startsim-sfms2) — a personal connection's config is the member's own
|
|
821
|
+
* privacy boundary — and it is a separate verb precisely so opening it does
|
|
822
|
+
* not open the capability Save. This stub does not carry it, and this
|
|
823
|
+
* connector declares no config fields, so no settings control renders here;
|
|
824
|
+
* `member-connection-scope.test.tsx` drives the surface that does.
|
|
819
825
|
* * NO targets, choices, actions or tokens. Those are foundry-level and stay
|
|
820
826
|
* on the control-plane router, so the Workflows control is not rendered.
|
|
821
827
|
* * THE CATALOG IS THE SERVER'S ANSWER. `me/catalog/` returns only what the
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RED: a member's own scope is theirs to set, on screen (bd startsim-sirp6).
|
|
3
|
+
*
|
|
4
|
+
* Quinn, 2026-08-27: "if people connect their dropbox, they will expect to be
|
|
5
|
+
* able to configure what they have access to. some may want root which would be
|
|
6
|
+
* everything, some may want to configure a subfolder. but our system should
|
|
7
|
+
* enable and enforce either / both."
|
|
8
|
+
*
|
|
9
|
+
* `connections.test.tsx` next door pins that personal mode has no PATCH, and
|
|
10
|
+
* that was right about what the router served. It is not right about a PERSONAL
|
|
11
|
+
* connection's config, which is not a pipeline setting: it decides which of the
|
|
12
|
+
* MEMBER's own files this platform reads. So the member surface grows exactly
|
|
13
|
+
* ONE write verb — `PATCH connections/me/<id>/config/`, beside `credential/`
|
|
14
|
+
* rather than on the detail route — and this file pins what that must look like
|
|
15
|
+
* from the browser.
|
|
16
|
+
*
|
|
17
|
+
* THREE THINGS IT IS EASY TO SHIP WITHOUT.
|
|
18
|
+
*
|
|
19
|
+
* 1. THE CONTROL HAS TO RENDER. `ConnectionCard` gates Settings on a verb
|
|
20
|
+
* EXISTING on the bound api. A member write endpoint that the composer never
|
|
21
|
+
* reaches is a change every backend test passes and no member can use, so the
|
|
22
|
+
* first assertion here is positive: the control is on the card.
|
|
23
|
+
* 2. THE CAPABILITY SAVE MUST STAY ABSENT. The narrow verb is narrow. What a
|
|
24
|
+
* member's account may DO is still the foundry's, and the picker stays
|
|
25
|
+
* informational — which is why the composer must not gate both controls on
|
|
26
|
+
* one verb.
|
|
27
|
+
* 3. THE MEMBER MUST BE TOLD THE WALK RESTARTS, and the SERVER must be what says
|
|
28
|
+
* it. This composer refuses to name a vendor and cannot know which fields
|
|
29
|
+
* bear the scope; only the connector knows. So the sentence arrives in the
|
|
30
|
+
* response and is rendered verbatim.
|
|
31
|
+
*/
|
|
32
|
+
import * as React from 'react';
|
|
33
|
+
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
34
|
+
import userEvent from '@testing-library/user-event';
|
|
35
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
36
|
+
|
|
37
|
+
import { ConnectionsPage } from '../components/pages/ConnectionsPage';
|
|
38
|
+
import { CredentialSchemaForm } from '../components/CredentialSchemaForm';
|
|
39
|
+
import type { MemberConnectionsApi } from '../components/pages/types';
|
|
40
|
+
import { connectionsMe } from '../api/connections';
|
|
41
|
+
import { setApiBase, setAuthTokenProvider } from '../api/_fetch';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A connector whose config IS somebody's privacy boundary: a folder, a switch
|
|
45
|
+
* that widens it, and a claim on the widest setting of all.
|
|
46
|
+
*
|
|
47
|
+
* Not Dropbox. This composer knows about FIELDS and nothing about vendors, so a
|
|
48
|
+
* fixture that named one would be asserting Dropbox rather than the framework.
|
|
49
|
+
*/
|
|
50
|
+
const SCOPE_FIELDS = [
|
|
51
|
+
{
|
|
52
|
+
key: 'folder', label: 'Folder', type: 'string', required: false,
|
|
53
|
+
help: 'Leave blank for everything', placeholder: '', pattern: null, options: null,
|
|
54
|
+
default: '', write_only: false, mask: 'none', docs_url: '',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
key: 'recursive', label: 'Include sub-folders', type: 'boolean', required: false,
|
|
58
|
+
help: 'Off by default', placeholder: '', pattern: null, options: null,
|
|
59
|
+
default: false, write_only: false, mask: 'none', docs_url: '',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
key: 'whole_account_acknowledged_by', label: 'Whole-account read claimed by',
|
|
63
|
+
type: 'acknowledgement', required: false, help: 'Required to read everything',
|
|
64
|
+
placeholder: '', pattern: null, options: null, default: null,
|
|
65
|
+
write_only: false, mask: 'none', docs_url: '',
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const MEMBER_CATALOG = {
|
|
70
|
+
results: [
|
|
71
|
+
{
|
|
72
|
+
key: 'filesvendor', name: 'File Vendor', description: 'Files', logo_url: '',
|
|
73
|
+
docs_url: '', capabilities: ['poll'], auth_mode: 'api_key',
|
|
74
|
+
credential_schema: {
|
|
75
|
+
version: 1,
|
|
76
|
+
fields: [{
|
|
77
|
+
key: 'api_key', label: 'API key', type: 'secret', required: true, help: '',
|
|
78
|
+
placeholder: '', pattern: null, options: null, default: null,
|
|
79
|
+
write_only: true, mask: 'last4', docs_url: '',
|
|
80
|
+
}],
|
|
81
|
+
},
|
|
82
|
+
config_schema: { version: 1, fields: SCOPE_FIELDS },
|
|
83
|
+
record_types: [], supports_webhooks: false,
|
|
84
|
+
availability: { available: true, reason: '' },
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const MY_CONNECTIONS = {
|
|
90
|
+
results: [
|
|
91
|
+
{
|
|
92
|
+
id: 'm1', public_id: 'mpub1', name: 'My files', connector_key: 'filesvendor',
|
|
93
|
+
connector_name: 'File Vendor', external_account_id: '',
|
|
94
|
+
account_label: 'me@example.com', capabilities_enabled: ['poll'],
|
|
95
|
+
config: { folder: '/Contracts', recursive: false }, status: 'connected',
|
|
96
|
+
health: 'ok', auth_mode: 'api_key',
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
permissions: { view: true, manage: true, issue_tokens: false },
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function stubMemberApi(overrides: Partial<MemberConnectionsApi> = {}): MemberConnectionsApi {
|
|
103
|
+
return {
|
|
104
|
+
connectorCatalog: vi.fn().mockResolvedValue(MEMBER_CATALOG),
|
|
105
|
+
connections: vi.fn().mockResolvedValue(MY_CONNECTIONS),
|
|
106
|
+
connect: vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]),
|
|
107
|
+
checkConnection: vi.fn().mockResolvedValue({ ok: true, account_label: '', detail: 'ok' }),
|
|
108
|
+
rotateCredential: vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]),
|
|
109
|
+
disconnect: vi.fn().mockResolvedValue(undefined),
|
|
110
|
+
connectionRuns: vi.fn().mockResolvedValue({ results: [] }),
|
|
111
|
+
updateConnectionConfig: vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]),
|
|
112
|
+
...overrides,
|
|
113
|
+
} as MemberConnectionsApi;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function myCard(api: MemberConnectionsApi = stubMemberApi()) {
|
|
117
|
+
render(<ConnectionsPage mode="personal" api={api} />);
|
|
118
|
+
const card = await screen.findByText('My files');
|
|
119
|
+
return (card.closest('div[data-connection]') ?? card.parentElement!) as HTMLElement;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --------------------------------------------------------------------------
|
|
123
|
+
// the control exists, and it is the narrow one
|
|
124
|
+
// --------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
describe('the member card can change its own scope', () => {
|
|
127
|
+
it('offers Settings on a connector that declares config fields', async () => {
|
|
128
|
+
const section = await myCard();
|
|
129
|
+
expect(
|
|
130
|
+
within(section).getByRole('button', { name: /Settings/i }),
|
|
131
|
+
).toBeInTheDocument();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('still offers no Save beside the capability picker', async () => {
|
|
135
|
+
// The verb is narrow ON PURPOSE. What a member's account may DO stays the
|
|
136
|
+
// foundry's, so gating both controls on one verb would hand back exactly the
|
|
137
|
+
// decision this change did not make.
|
|
138
|
+
const section = await myCard();
|
|
139
|
+
expect(within(section).queryByRole('button', { name: /^Save$/i })).toBeNull();
|
|
140
|
+
expect(
|
|
141
|
+
within(section).getByText(/Ask an admin to change what this account may do/i),
|
|
142
|
+
).toBeInTheDocument();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('saves the fields the member changed, and nothing else', async () => {
|
|
146
|
+
const updateConnectionConfig = vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]);
|
|
147
|
+
const section = await myCard(stubMemberApi({ updateConnectionConfig }));
|
|
148
|
+
|
|
149
|
+
await userEvent.click(within(section).getByRole('button', { name: /Settings/i }));
|
|
150
|
+
await userEvent.clear(within(section).getByLabelText(/^Folder$/i));
|
|
151
|
+
await userEvent.type(within(section).getByLabelText(/^Folder$/i), '/Invoices');
|
|
152
|
+
await userEvent.click(within(section).getByRole('button', { name: /Save settings/i }));
|
|
153
|
+
|
|
154
|
+
await waitFor(() => expect(updateConnectionConfig).toHaveBeenCalled());
|
|
155
|
+
expect(updateConnectionConfig.mock.calls[0]).toEqual(['m1', { folder: '/Invoices' }]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('sends a real boolean when a switch is turned ON', async () => {
|
|
159
|
+
// `bool("false")` is `true`. A switch that travels as a string is the whole
|
|
160
|
+
// reason `type` exists on a field spec.
|
|
161
|
+
const updateConnectionConfig = vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]);
|
|
162
|
+
const section = await myCard(stubMemberApi({ updateConnectionConfig }));
|
|
163
|
+
|
|
164
|
+
await userEvent.click(within(section).getByRole('button', { name: /Settings/i }));
|
|
165
|
+
await userEvent.click(within(section).getByLabelText(/Include sub-folders/i));
|
|
166
|
+
await userEvent.click(within(section).getByRole('button', { name: /Save settings/i }));
|
|
167
|
+
|
|
168
|
+
await waitFor(() => expect(updateConnectionConfig).toHaveBeenCalled());
|
|
169
|
+
expect(updateConnectionConfig.mock.calls[0][1]).toEqual({ recursive: true });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('sends a real boolean when a switch is turned OFF', async () => {
|
|
173
|
+
// Against a MERGING api, a client that drops falsy values makes the off
|
|
174
|
+
// position literally unreachable — for a scope switch that is the narrow
|
|
175
|
+
// setting nobody can get back to.
|
|
176
|
+
const updateConnectionConfig = vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]);
|
|
177
|
+
const api = stubMemberApi({
|
|
178
|
+
updateConnectionConfig,
|
|
179
|
+
connections: vi.fn().mockResolvedValue({
|
|
180
|
+
...MY_CONNECTIONS,
|
|
181
|
+
results: [{
|
|
182
|
+
...MY_CONNECTIONS.results[0],
|
|
183
|
+
config: { folder: '/Contracts', recursive: true },
|
|
184
|
+
}],
|
|
185
|
+
}),
|
|
186
|
+
});
|
|
187
|
+
const section = await myCard(api);
|
|
188
|
+
|
|
189
|
+
await userEvent.click(within(section).getByRole('button', { name: /Settings/i }));
|
|
190
|
+
await userEvent.click(within(section).getByLabelText(/Include sub-folders/i));
|
|
191
|
+
await userEvent.click(within(section).getByRole('button', { name: /Save settings/i }));
|
|
192
|
+
|
|
193
|
+
await waitFor(() => expect(updateConnectionConfig).toHaveBeenCalled());
|
|
194
|
+
expect(updateConnectionConfig.mock.calls[0][1]).toEqual({ recursive: false });
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// --------------------------------------------------------------------------
|
|
199
|
+
// what the server says, the member reads
|
|
200
|
+
// --------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
describe('the member is told what the change costs', () => {
|
|
203
|
+
it('renders the notices the SERVER returned, verbatim', async () => {
|
|
204
|
+
const sentence =
|
|
205
|
+
'Changing what this connection covers restarts the walk: the next run '
|
|
206
|
+
+ 're-reads this account from the beginning.';
|
|
207
|
+
const updateConnectionConfig = vi.fn().mockResolvedValue({
|
|
208
|
+
...MY_CONNECTIONS.results[0], notices: [sentence],
|
|
209
|
+
});
|
|
210
|
+
const section = await myCard(stubMemberApi({ updateConnectionConfig }));
|
|
211
|
+
|
|
212
|
+
await userEvent.click(within(section).getByRole('button', { name: /Settings/i }));
|
|
213
|
+
await userEvent.click(within(section).getByLabelText(/Include sub-folders/i));
|
|
214
|
+
await userEvent.click(within(section).getByRole('button', { name: /Save settings/i }));
|
|
215
|
+
|
|
216
|
+
await waitFor(() => expect(screen.getByText(sentence)).toBeInTheDocument());
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('says nothing extra when the server said nothing', async () => {
|
|
220
|
+
const section = await myCard();
|
|
221
|
+
await userEvent.click(within(section).getByRole('button', { name: /Settings/i }));
|
|
222
|
+
await userEvent.click(within(section).getByLabelText(/Include sub-folders/i));
|
|
223
|
+
await userEvent.click(within(section).getByRole('button', { name: /Save settings/i }));
|
|
224
|
+
await waitFor(() =>
|
|
225
|
+
expect(within(section).getByText(/Settings saved/i)).toBeInTheDocument());
|
|
226
|
+
expect(within(section).queryByText(/from the beginning/i)).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// --------------------------------------------------------------------------
|
|
231
|
+
// an acknowledgement is a control, and it shows who claimed it
|
|
232
|
+
// --------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
describe('CredentialSchemaForm renders an acknowledgement as consent', () => {
|
|
235
|
+
function renderAck(stored: unknown, onChange = vi.fn()) {
|
|
236
|
+
render(
|
|
237
|
+
<CredentialSchemaForm
|
|
238
|
+
fields={SCOPE_FIELDS}
|
|
239
|
+
values={{ whole_account_acknowledged_by: stored }}
|
|
240
|
+
onChange={onChange}
|
|
241
|
+
/>,
|
|
242
|
+
);
|
|
243
|
+
return onChange;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
it('is a checkbox, not a box to type somebody’s name into', () => {
|
|
247
|
+
renderAck('');
|
|
248
|
+
const control = screen.getByLabelText(/Whole-account read claimed by/i);
|
|
249
|
+
expect(control).toHaveAttribute('type', 'checkbox');
|
|
250
|
+
expect((control as HTMLInputElement).checked).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('emits a boolean, because the SERVER writes who and when', () => {
|
|
254
|
+
const onChange = renderAck('');
|
|
255
|
+
return userEvent.click(screen.getByLabelText(/Whole-account read claimed by/i)).then(() => {
|
|
256
|
+
expect(onChange).toHaveBeenCalledWith('whole_account_acknowledged_by', true);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('shows the recorded attribution once somebody has claimed it', () => {
|
|
261
|
+
// A control that records nothing would be worse than the text box it
|
|
262
|
+
// replaced. The record has to be READABLE, or nobody can check it.
|
|
263
|
+
renderAck('dana.reed@example.com on 2026-08-27T09:00:00Z');
|
|
264
|
+
const control = screen.getByLabelText(/Whole-account read claimed by/i) as HTMLInputElement;
|
|
265
|
+
expect(control.checked).toBe(true);
|
|
266
|
+
expect(screen.getByText(/dana\.reed@example\.com/)).toBeInTheDocument();
|
|
267
|
+
expect(screen.getByText(/2026-08-27T09:00:00Z/)).toBeInTheDocument();
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// --------------------------------------------------------------------------
|
|
272
|
+
// and the client lands on the narrow route
|
|
273
|
+
// --------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
describe('connectionsMe.updateConnectionConfig', () => {
|
|
276
|
+
interface Call { url: string; method: string; body: unknown }
|
|
277
|
+
let calls: Call[] = [];
|
|
278
|
+
|
|
279
|
+
beforeEach(() => {
|
|
280
|
+
calls = [];
|
|
281
|
+
setApiBase('/api/v1');
|
|
282
|
+
setAuthTokenProvider(null);
|
|
283
|
+
vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit = {}) => {
|
|
284
|
+
calls.push({
|
|
285
|
+
url: String(url),
|
|
286
|
+
method: init.method ?? 'GET',
|
|
287
|
+
body: init.body ? JSON.parse(String(init.body)) : undefined,
|
|
288
|
+
});
|
|
289
|
+
return new Response(JSON.stringify({ id: 'm1' }), {
|
|
290
|
+
status: 200, headers: { 'Content-Type': 'application/json' },
|
|
291
|
+
});
|
|
292
|
+
}));
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
afterEach(() => { vi.unstubAllGlobals(); });
|
|
296
|
+
|
|
297
|
+
it('PATCHes the CONFIG sub-route, not the detail route', async () => {
|
|
298
|
+
// Beside `credential/`, and for the same reason it is: a scope change has to
|
|
299
|
+
// pass the connector's own refusal before it replaces a working setting.
|
|
300
|
+
// The detail route keeps having no PATCH at all.
|
|
301
|
+
await connectionsMe.updateConnectionConfig!('abc', { folder: '/Invoices' });
|
|
302
|
+
expect(calls[0].url).toBe('/api/v1/connections/me/abc/config/');
|
|
303
|
+
expect(calls[0].method).toBe('PATCH');
|
|
304
|
+
expect(calls[0].body).toEqual({ config: { folder: '/Invoices' } });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('names no slug, no foundry and no user', async () => {
|
|
308
|
+
await connectionsMe.updateConnectionConfig!('abc', { folder: '/Invoices' });
|
|
309
|
+
expect(calls[0].url.startsWith('/api/v1/connections/me/')).toBe(true);
|
|
310
|
+
expect(calls[0].url).not.toMatch(/[?&]/);
|
|
311
|
+
expect(JSON.stringify(calls[0].body)).not.toMatch(
|
|
312
|
+
/slug|foundry|tenant|company|owner|user_id/i);
|
|
313
|
+
});
|
|
314
|
+
});
|
package/src/api/connections.ts
CHANGED
|
@@ -18,11 +18,18 @@
|
|
|
18
18
|
* token in flight decides which foundry answers, and nothing here can override
|
|
19
19
|
* that.
|
|
20
20
|
*
|
|
21
|
-
* WHAT IS ABSENT IS ALSO THE CONTRACT. There is no `updateConnection
|
|
22
|
-
* `MyConnectionDetailView` serves GET and DELETE only
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* for a route that does
|
|
21
|
+
* WHAT IS ABSENT IS ALSO THE CONTRACT. There is still no `updateConnection`:
|
|
22
|
+
* `MyConnectionDetailView` serves GET and DELETE only, so a card's name and what
|
|
23
|
+
* it may DO stay with the foundry. No choices, targets, actions or
|
|
24
|
+
* `ConnectionToken`s either — those are foundry-level and owner-exclusive on the
|
|
25
|
+
* control-plane router. Adding one here would be a client for a route that does
|
|
26
|
+
* not exist.
|
|
27
|
+
*
|
|
28
|
+
* `updateConnectionConfig` is the ONE write verb that was added (bd
|
|
29
|
+
* startsim-sfms2), and it points at `me/<id>/config/` rather than at the detail
|
|
30
|
+
* route for the same reason `rotateCredential` points at `credential/`: a change
|
|
31
|
+
* that has to pass a gate gets its own route instead of a field on a general
|
|
32
|
+
* PATCH.
|
|
26
33
|
*
|
|
27
34
|
* WHERE IT POINTS. Through `request()`, so `setApiBase` and
|
|
28
35
|
* `setAuthTokenProvider` apply: a tenant app points the base at its own
|
|
@@ -155,6 +162,37 @@ export const connectionsMe: MemberConnectionsApi = {
|
|
|
155
162
|
});
|
|
156
163
|
},
|
|
157
164
|
|
|
165
|
+
/**
|
|
166
|
+
* CHANGE THE SETTINGS OF MY OWN CARD (bd startsim-sfms2).
|
|
167
|
+
*
|
|
168
|
+
* The folder a Dropbox connection reads, and whether it recurses. On a
|
|
169
|
+
* personal connection that is not a pipeline setting — it is which of the
|
|
170
|
+
* member's own files this platform is allowed to see — so it is theirs to set,
|
|
171
|
+
* and the alternative was disconnect + re-consent, which throws a working
|
|
172
|
+
* token away to change a string.
|
|
173
|
+
*
|
|
174
|
+
* A SUB-ROUTE, not a PATCH on the detail view. `credential/` is the precedent
|
|
175
|
+
* one door down: rotation is not on PATCH either, because a new key has to
|
|
176
|
+
* pass `check()` before it replaces a working one. A scope change has the same
|
|
177
|
+
* shape — it must pass the connector's own refusal, and it restarts the walk.
|
|
178
|
+
*
|
|
179
|
+
* PARTIAL and MERGING, exactly like `rotateCredential`: send the fields that
|
|
180
|
+
* changed and the stored values fill in the rest. `null` unsets one. The server
|
|
181
|
+
* refuses any key the connector does not declare, and refuses any TOP-LEVEL key
|
|
182
|
+
* that is not `config` — a body naming `capabilities_enabled` is a 400 and not
|
|
183
|
+
* a silent no-op.
|
|
184
|
+
*
|
|
185
|
+
* The response may carry `notices`: sentences the server wrote about what the
|
|
186
|
+
* change costs. Rendered verbatim, never composed in the browser.
|
|
187
|
+
*/
|
|
188
|
+
updateConnectionConfig(
|
|
189
|
+
id: string, config: Record<string, unknown>,
|
|
190
|
+
): Promise<Connection> {
|
|
191
|
+
return request<Connection>(`${BASE}/${id}/config/`, {
|
|
192
|
+
method: 'PATCH', body: { config },
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
|
|
158
196
|
/**
|
|
159
197
|
* DISCONNECT, not delete. One transaction stops the scheduled fetches, revokes
|
|
160
198
|
* every token issued for the account and deletes the stored credential; the
|
|
@@ -51,6 +51,9 @@ export interface CredentialSchemaFormProps {
|
|
|
51
51
|
/** Field types this build renders natively. Anything else becomes text. */
|
|
52
52
|
const NATIVE_TYPES = new Set(['text', 'url', 'email', 'number', 'select', 'textarea']);
|
|
53
53
|
|
|
54
|
+
/** The server's spelling for a consent record. See `isAcknowledgement`. */
|
|
55
|
+
const ACKNOWLEDGEMENT = 'acknowledgement';
|
|
56
|
+
|
|
54
57
|
/**
|
|
55
58
|
* A boolean is a CHECKBOX, and this is not a cosmetic preference.
|
|
56
59
|
*
|
|
@@ -61,12 +64,37 @@ const NATIVE_TYPES = new Set(['text', 'url', 'email', 'number', 'select', 'texta
|
|
|
61
64
|
* conversations are stored, and in a text box the way you turn it off is to type
|
|
62
65
|
* the word "false" — which `bool()` reads as ON.
|
|
63
66
|
*/
|
|
64
|
-
const BOOLEAN_TYPES = new Set(['boolean', 'checkbox', 'bool']);
|
|
67
|
+
const BOOLEAN_TYPES = new Set(['boolean', 'checkbox', 'bool', ACKNOWLEDGEMENT]);
|
|
65
68
|
|
|
66
69
|
export function isBoolean(spec: ConnectorFieldSpec): boolean {
|
|
67
70
|
return !isSecret(spec) && BOOLEAN_TYPES.has(String(spec.type || '').toLowerCase());
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
/**
|
|
74
|
+
* An ACKNOWLEDGEMENT is a consent record, not a setting (bd startsim-sfms2).
|
|
75
|
+
*
|
|
76
|
+
* It marks the fields that say a named human claimed a disclosure that is legal,
|
|
77
|
+
* large, and much bigger than most people mean — Dropbox's
|
|
78
|
+
* `whole_account_acknowledged_by`, which gates reading metadata for every file
|
|
79
|
+
* in somebody's account.
|
|
80
|
+
*
|
|
81
|
+
* Two things follow, and both are why it is not merely `boolean`. What the
|
|
82
|
+
* person DOES is tick a claim, so this emits `true`/`false` and the SERVER
|
|
83
|
+
* writes `<who> on <when>` from the authenticated caller — there is no box to
|
|
84
|
+
* type somebody else's name into, and no request body that can attribute a claim
|
|
85
|
+
* to anyone but its sender. And what the field HOLDS is that attribution, so it
|
|
86
|
+
* is rendered beside the control: a claim nobody can read is a record in name
|
|
87
|
+
* only, which is worse than the text box it replaced.
|
|
88
|
+
*/
|
|
89
|
+
export function isAcknowledgement(spec: ConnectorFieldSpec): boolean {
|
|
90
|
+
return !isSecret(spec) && String(spec.type || '').toLowerCase() === ACKNOWLEDGEMENT;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What the server wrote into an acknowledgement, or '' when nobody has claimed it. */
|
|
94
|
+
export function acknowledgedBy(value: unknown): string {
|
|
95
|
+
return typeof value === 'string' && asBoolean(value) ? value.trim() : '';
|
|
96
|
+
}
|
|
97
|
+
|
|
70
98
|
/** The spellings of "off" that can arrive from a stored config or a form. */
|
|
71
99
|
export function asBoolean(value: unknown): boolean {
|
|
72
100
|
if (typeof value === 'string') {
|
|
@@ -185,6 +213,15 @@ export function CredentialSchemaForm({
|
|
|
185
213
|
/>
|
|
186
214
|
)}
|
|
187
215
|
|
|
216
|
+
{/* THE RECORD, beside the control that made it. `who` and `when`
|
|
217
|
+
are written by the server; nothing here can author or edit them.
|
|
218
|
+
Rendered because a consent record nobody can read is not one. */}
|
|
219
|
+
{isAcknowledgement(spec) && acknowledgedBy(stored) ? (
|
|
220
|
+
<p className={classNames.help ?? 'mt-1 text-xs text-gray-500'}>
|
|
221
|
+
Claimed by {acknowledgedBy(stored)}
|
|
222
|
+
</p>
|
|
223
|
+
) : null}
|
|
224
|
+
|
|
188
225
|
{spec.help ? (
|
|
189
226
|
<p className={classNames.help ?? 'mt-1 text-xs text-gray-500'}>{spec.help}</p>
|
|
190
227
|
) : null}
|
|
@@ -111,6 +111,20 @@ interface BoundApi {
|
|
|
111
111
|
updateConnection?(id: string, input: {
|
|
112
112
|
name?: string; capabilities_enabled?: string[]; config?: Record<string, unknown>;
|
|
113
113
|
}): Promise<Connection>;
|
|
114
|
+
/**
|
|
115
|
+
* SETTINGS ALONE, and separate from `updateConnection` on purpose
|
|
116
|
+
* (bd startsim-sfms2).
|
|
117
|
+
*
|
|
118
|
+
* Both surfaces have it; only the control plane has `updateConnection`. A
|
|
119
|
+
* personal connection's config is the MEMBER's privacy boundary — which of
|
|
120
|
+
* their own files this platform reads — while its name and what it may DO are
|
|
121
|
+
* the foundry's. One verb carrying all three would have made the Settings
|
|
122
|
+
* control and the capability Save appear together, so opening the first would
|
|
123
|
+
* have opened the second by accident.
|
|
124
|
+
*/
|
|
125
|
+
updateConnectionConfig?(
|
|
126
|
+
id: string, config: Record<string, unknown>,
|
|
127
|
+
): Promise<Connection>;
|
|
114
128
|
connectionChoices?(id: string, params?: { kind?: string; cursor?: string }):
|
|
115
129
|
Promise<ConnectionChoicePage>;
|
|
116
130
|
wireTarget?(id: string, input: {
|
|
@@ -177,6 +191,12 @@ function bindApi(props: ConnectionsPageProps): BoundApi {
|
|
|
177
191
|
rotateCredential: (id, credentials) => api.rotateCredential(slug, id, credentials),
|
|
178
192
|
disconnect: (id) => api.disconnect(slug, id),
|
|
179
193
|
updateConnection: (id, input) => api.updateConnection(slug, id, input),
|
|
194
|
+
// The control plane reaches settings through the same PATCH it reaches
|
|
195
|
+
// everything else with; the narrow verb is a property of the MEMBER router,
|
|
196
|
+
// not of the form. Both modes therefore satisfy the same bound signature and
|
|
197
|
+
// the settings form never learns which surface it is on.
|
|
198
|
+
updateConnectionConfig: (id, config) =>
|
|
199
|
+
api.updateConnection(slug, id, { config }),
|
|
180
200
|
connectionChoices: (id, params) => api.connectionChoices(slug, id, params),
|
|
181
201
|
wireTarget: (id, input) => api.wireTarget(slug, id, input),
|
|
182
202
|
confirmTarget: (id, destinationId) => api.confirmTarget(slug, id, destinationId),
|
|
@@ -971,7 +991,8 @@ function ConnectionSettingsForm({
|
|
|
971
991
|
connection: Connection;
|
|
972
992
|
fields: ConnectorFieldSpec[];
|
|
973
993
|
classNames: typeof DEFAULTS;
|
|
974
|
-
|
|
994
|
+
/** Handed whatever the SERVER said about the change, so the card can say it. */
|
|
995
|
+
onDone: (notices: string[]) => void;
|
|
975
996
|
}) {
|
|
976
997
|
const stored = React.useMemo(
|
|
977
998
|
() => (connection.config ?? {}) as Record<string, unknown>,
|
|
@@ -1001,8 +1022,15 @@ function ConnectionSettingsForm({
|
|
|
1001
1022
|
? Number(value)
|
|
1002
1023
|
: value;
|
|
1003
1024
|
}
|
|
1004
|
-
await api.
|
|
1005
|
-
|
|
1025
|
+
const saved = await api.updateConnectionConfig!(connection.id, config);
|
|
1026
|
+
// WHAT THE SERVER WANTS THEM TO KNOW, handed up rather than rendered here.
|
|
1027
|
+
// The live case is a scope change restarting the walk, and this file
|
|
1028
|
+
// cannot work that out: it names no vendor and therefore cannot know which
|
|
1029
|
+
// fields bear the scope. A heuristic on field names would be both a vendor
|
|
1030
|
+
// branch and untestable at the level that matters. Handed to the CARD
|
|
1031
|
+
// because saving closes this panel, and a sentence that unmounts with the
|
|
1032
|
+
// form is one nobody reads.
|
|
1033
|
+
onDone(saved?.notices ?? []);
|
|
1006
1034
|
} catch (err) {
|
|
1007
1035
|
setError((err as { message?: string })?.message || 'Those settings were not saved.');
|
|
1008
1036
|
} finally {
|
|
@@ -1055,6 +1083,8 @@ function ConnectionCard({
|
|
|
1055
1083
|
const [busy, setBusy] = React.useState('');
|
|
1056
1084
|
const [error, setError] = React.useState('');
|
|
1057
1085
|
const [notice, setNotice] = React.useState('');
|
|
1086
|
+
/** Sentences the SERVER wrote about the last settings save. Never composed here. */
|
|
1087
|
+
const [notices, setNotices] = React.useState<string[]>([]);
|
|
1058
1088
|
const [replacing, setReplacing] = React.useState(false);
|
|
1059
1089
|
const [settingsOpen, setSettingsOpen] = React.useState(false);
|
|
1060
1090
|
const [confirmingDisconnect, setConfirmingDisconnect] = React.useState(false);
|
|
@@ -1164,8 +1194,16 @@ function ConnectionCard({
|
|
|
1164
1194
|
all, which for Granola means the switch deciding whether verbatim
|
|
1165
1195
|
recordings of people's conversations are stored was reachable
|
|
1166
1196
|
only by editing the database. `ReplaceKeyForm` already pointed at
|
|
1167
|
-
"the connection's own settings" as if this existed.
|
|
1168
|
-
|
|
1197
|
+
"the connection's own settings" as if this existed.
|
|
1198
|
+
|
|
1199
|
+
GATED ON THE SETTINGS VERB, not on the general one (bd
|
|
1200
|
+
startsim-sfms2). The member surface has `updateConnectionConfig`
|
|
1201
|
+
and deliberately not `updateConnection`, because a personal
|
|
1202
|
+
connection's config is the MEMBER's privacy boundary while its
|
|
1203
|
+
name and what it may do stay the foundry's. One gate for both
|
|
1204
|
+
would have made opening this control open the capability Save
|
|
1205
|
+
beside it, which is a decision this change did not make. */}
|
|
1206
|
+
{configFields.length && api.updateConnectionConfig ? (
|
|
1169
1207
|
<button
|
|
1170
1208
|
type="button"
|
|
1171
1209
|
className={cn.button}
|
|
@@ -1202,7 +1240,12 @@ function ConnectionCard({
|
|
|
1202
1240
|
connection={connection}
|
|
1203
1241
|
fields={configFields}
|
|
1204
1242
|
classNames={cn}
|
|
1205
|
-
onDone={() => {
|
|
1243
|
+
onDone={(said) => {
|
|
1244
|
+
setSettingsOpen(false);
|
|
1245
|
+
setNotice('Settings saved.');
|
|
1246
|
+
setNotices(said);
|
|
1247
|
+
onChanged();
|
|
1248
|
+
}}
|
|
1206
1249
|
/>
|
|
1207
1250
|
) : null}
|
|
1208
1251
|
|
|
@@ -1308,6 +1351,11 @@ function ConnectionCard({
|
|
|
1308
1351
|
</CapabilityPicker>
|
|
1309
1352
|
|
|
1310
1353
|
{notice ? <p className={cn.muted}>{notice}</p> : null}
|
|
1354
|
+
{/* WHAT THE CHANGE COSTS, in the server's words. Rendered verbatim: this
|
|
1355
|
+
file names no vendor and cannot know which settings restart a walk. */}
|
|
1356
|
+
{notices.map((said) => (
|
|
1357
|
+
<p key={said} className={cn.muted}>{said}</p>
|
|
1358
|
+
))}
|
|
1311
1359
|
{error ? <p className={cn.error} role="alert">{error}</p> : null}
|
|
1312
1360
|
|
|
1313
1361
|
{choices ? (
|
|
@@ -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. */}
|
|
@@ -29,7 +29,12 @@ export interface ConnectorFieldSpec {
|
|
|
29
29
|
help?: string;
|
|
30
30
|
placeholder?: string;
|
|
31
31
|
options?: Array<{ value: string; label: string }> | null;
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* What happens if nobody touches this field. `unknown`, not `string`: a config
|
|
34
|
+
* schema declares booleans and numbers, and a `default: false` typed as a
|
|
35
|
+
* string is the same class of lie as a checkbox rendered as a text box.
|
|
36
|
+
*/
|
|
37
|
+
default?: unknown;
|
|
33
38
|
pattern?: string | null;
|
|
34
39
|
docs_url?: string;
|
|
35
40
|
}
|
|
@@ -148,6 +153,16 @@ export interface Connection {
|
|
|
148
153
|
auth_mode: string;
|
|
149
154
|
check_error?: string;
|
|
150
155
|
last_checked_at?: string | null;
|
|
156
|
+
/**
|
|
157
|
+
* WHAT THE SERVER WANTS THE PERSON WHO JUST SAVED TO KNOW. Present on the
|
|
158
|
+
* response to a config write and NOWHERE else — never stored, never on a GET.
|
|
159
|
+
*
|
|
160
|
+
* The live case is a scope change: it restarts the walk, because the connector
|
|
161
|
+
* discards a sync cursor whose scope no longer matches. This composer refuses
|
|
162
|
+
* to name a vendor and cannot know which fields bear the scope; only the
|
|
163
|
+
* connector does, so only the server can say it. Rendered verbatim.
|
|
164
|
+
*/
|
|
165
|
+
notices?: string[];
|
|
151
166
|
}
|
|
152
167
|
|
|
153
168
|
/** What to RENDER, never what to allow. The server refuses on its own. */
|
|
@@ -389,11 +404,37 @@ export interface MemberConnectionsApi {
|
|
|
389
404
|
config?: Record<string, unknown>;
|
|
390
405
|
/**
|
|
391
406
|
* Chosen once, at connect time, and not editable afterwards on this
|
|
392
|
-
* surface —
|
|
393
|
-
*
|
|
407
|
+
* surface — the detail route has no PATCH and `config/` accepts nothing
|
|
408
|
+
* else. The picker on an existing card is therefore informational, and
|
|
409
|
+
* saying so is the honest rendering of both halves.
|
|
394
410
|
*/
|
|
395
411
|
capabilities_enabled?: string[];
|
|
396
412
|
}): Promise<Connection>;
|
|
413
|
+
/**
|
|
414
|
+
* CHANGE THE SETTINGS OF A CONNECTION I OWN (bd startsim-sfms2).
|
|
415
|
+
*
|
|
416
|
+
* The one write verb this surface grew, and it is narrow on purpose. A
|
|
417
|
+
* personal connection's config is not a pipeline setting: it names the folder
|
|
418
|
+
* of somebody's own account this platform may read. Routing that through an
|
|
419
|
+
* admin makes another person the author of that member's privacy boundary,
|
|
420
|
+
* and the only re-scope available without it was disconnect + re-consent —
|
|
421
|
+
* throwing away a working token to change a string.
|
|
422
|
+
*
|
|
423
|
+
* NOT `updateConnection`, and the difference is load-bearing: the foundry
|
|
424
|
+
* verb of that name also carries `name` and `capabilities_enabled`, which stay
|
|
425
|
+
* the foundry's. A composer that gated both controls on one verb would hand
|
|
426
|
+
* back a decision this change did not make.
|
|
427
|
+
*
|
|
428
|
+
* PARTIAL and MERGING: send the fields that changed. `null` unsets one. The
|
|
429
|
+
* server refuses any key the connector does not declare.
|
|
430
|
+
*
|
|
431
|
+
* Optional so an app pinned to an older `@startsimpli/api` keeps
|
|
432
|
+
* type-checking; the card renders the Settings control only when the verb is
|
|
433
|
+
* present, so a stale client degrades to no control rather than a dead button.
|
|
434
|
+
*/
|
|
435
|
+
updateConnectionConfig?(
|
|
436
|
+
id: string, config: Record<string, unknown>,
|
|
437
|
+
): Promise<Connection>;
|
|
397
438
|
checkConnection(id: string): Promise<{
|
|
398
439
|
ok: boolean; account_label: string; detail: string;
|
|
399
440
|
}>;
|
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
|