@startsimpli/integrations 0.2.26 → 0.2.28
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.28",
|
|
4
4
|
"description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OPENING ONE RECORD (bd startsim-4s4um RED / startsim-2lt8y GREEN).
|
|
3
|
+
*
|
|
4
|
+
* REPORTED, with a live URL:
|
|
5
|
+
* `https://tandem.ai.startsimpli.com/library?doc=5ab48b58-8573-4114-b408-215dd0eaf5d7`
|
|
6
|
+
* renders no document.
|
|
7
|
+
*
|
|
8
|
+
* WHAT THE TENANT ACTUALLY DOES, read out of
|
|
9
|
+
* `start-simpli-api/tenant-starter/apps/api/{views,filters}.py` @ origin/main
|
|
10
|
+
* rather than assumed:
|
|
11
|
+
*
|
|
12
|
+
* - `/api/v1/entities/<id>/` is a real DRF detail route. It answers the ONE
|
|
13
|
+
* row, or 404 when the row is absent for this reader — the read gate makes a
|
|
14
|
+
* forbidden row ABSENT rather than refused, deliberately.
|
|
15
|
+
* - `/api/v1/entities/` applies `?type=` plus a fixed vocabulary
|
|
16
|
+
* (`search`, `source`, `party`, `occurred_after`, `occurred_before`,
|
|
17
|
+
* `related_*`, `order`) and `attr.*`. `_note_unknown` sweeps EVERY OTHER
|
|
18
|
+
* KEY into `ignored_filters` and returns the page unnarrowed. `id` is not in
|
|
19
|
+
* that vocabulary, which is why `?id=<uuid>` comes back with count 180.
|
|
20
|
+
*
|
|
21
|
+
* SO THE FAKE BELOW IGNORES UNKNOWN PARAMETERS RATHER THAN ERRORING ON THEM,
|
|
22
|
+
* and that is the whole point of writing a new one instead of reaching for
|
|
23
|
+
* `createFixtureCorpusHttp`: that fixture applies `?search=` when a backend is
|
|
24
|
+
* named and never reports `ignored_filters`, so a lookup expressed as a filter
|
|
25
|
+
* would come back plausibly narrowed there. A fake that answers an unsupported
|
|
26
|
+
* filter with `[]` — or with a filtered page — makes this suite green while the
|
|
27
|
+
* deployed Library stays blank, which is the exact trap this bead names.
|
|
28
|
+
*
|
|
29
|
+
* WHAT IS NOT THE BUG, established before any of this was written. The DEPLOYED
|
|
30
|
+
* bundle on tandem.ai.startsimpli.com already asks the detail route:
|
|
31
|
+
*
|
|
32
|
+
* getDocument(e){let[a,r]=await Promise.all([
|
|
33
|
+
* t.get(`${m}/${encodeURIComponent(e)}`),f()]);...}
|
|
34
|
+
*
|
|
35
|
+
* and that slash-less path resolves — `curl` returns 401 with
|
|
36
|
+
* `allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS`, the detail route's own verb
|
|
37
|
+
* set, not a redirect and not the list's. The request shape is right. What is
|
|
38
|
+
* missing is everything that happens when it does not come back: the client
|
|
39
|
+
* accepts whatever it is handed without checking it is the record it asked for,
|
|
40
|
+
* and the composer turns every failure into an empty screen.
|
|
41
|
+
*/
|
|
42
|
+
import { describe, expect, it } from 'vitest';
|
|
43
|
+
import { render, screen, within } from '@testing-library/react';
|
|
44
|
+
|
|
45
|
+
import { LibraryPage } from '../components/pages/LibraryPage';
|
|
46
|
+
import { createCorpusApi } from '../corpus/client';
|
|
47
|
+
import type { CorpusHttp } from '../corpus/client';
|
|
48
|
+
import type { TenantEntity } from '../corpus/entities';
|
|
49
|
+
import type { CorpusScope } from '../corpus/types';
|
|
50
|
+
|
|
51
|
+
const SCOPE: CorpusScope = { orgId: 'org_tandem' };
|
|
52
|
+
|
|
53
|
+
/** The record the user deep-linked, live, when they reported this. */
|
|
54
|
+
const OPENED = '5ab48b58-8573-4114-b408-215dd0eaf5d7';
|
|
55
|
+
/** A well-formed id for a row that is simply not there. */
|
|
56
|
+
const ABSENT = '00000000-0000-4000-8000-000000000000';
|
|
57
|
+
|
|
58
|
+
function document(id: string, title: string, index: number): TenantEntity {
|
|
59
|
+
return {
|
|
60
|
+
id,
|
|
61
|
+
entity_type: 'source_document',
|
|
62
|
+
external_id: `granola:${id}`,
|
|
63
|
+
name: title,
|
|
64
|
+
org_id: SCOPE.orgId,
|
|
65
|
+
created_at: `2026-08-${String((index % 27) + 1).padStart(2, '0')}T10:00:00Z`,
|
|
66
|
+
data: {
|
|
67
|
+
source: 'granola',
|
|
68
|
+
title,
|
|
69
|
+
summary: `Summary of ${title}.`,
|
|
70
|
+
occurred_at: `2026-08-${String((index % 27) + 1).padStart(2, '0')}T09:00:00Z`,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A corpus the size of the reported one, with the deep-linked record buried
|
|
77
|
+
* where the composer's on-page shortcut cannot reach it.
|
|
78
|
+
*
|
|
79
|
+
* THE POSITION MATTERS. The record is 100 rows in, so page one does not contain
|
|
80
|
+
* it and `documents.find(...)` misses — which is precisely the state the
|
|
81
|
+
* reported URL lands in and the only state where the lookup is exercised at all.
|
|
82
|
+
*/
|
|
83
|
+
const CORPUS: TenantEntity[] = Array.from({ length: 180 }, (_, index) =>
|
|
84
|
+
index === 100
|
|
85
|
+
? document(OPENED, 'Tandem <> Obwb quarterly review', index)
|
|
86
|
+
: document(`doc-${index}`, `Record ${index}`, index),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
/** Parameters this tenant recognises on `/entities/`. Everything else is ignored. */
|
|
90
|
+
const HONOURED = new Set([
|
|
91
|
+
'search', 'source', 'party', 'occurred_after', 'occurred_before',
|
|
92
|
+
'related_to', 'related_via', 'related_direction', 'related_depth', 'order',
|
|
93
|
+
]);
|
|
94
|
+
const RESERVED = new Set(['page', 'page_size', 'format', 'type']);
|
|
95
|
+
|
|
96
|
+
interface Recorded {
|
|
97
|
+
path: string;
|
|
98
|
+
params: Record<string, unknown>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The tenant, as the tenant is.
|
|
103
|
+
*
|
|
104
|
+
* `detailStatus` stands in for the read gate and the session: the tenant answers
|
|
105
|
+
* an absent row with 404 and an expired bearer with 401, and those are different
|
|
106
|
+
* facts that must not collapse into the same screen.
|
|
107
|
+
*/
|
|
108
|
+
function tenantHttp(options: {
|
|
109
|
+
entities?: TenantEntity[];
|
|
110
|
+
pageSize?: number;
|
|
111
|
+
detailStatus?: number;
|
|
112
|
+
} = {}) {
|
|
113
|
+
const rows = options.entities ?? CORPUS;
|
|
114
|
+
const pageSize = options.pageSize ?? 20;
|
|
115
|
+
const requests: Recorded[] = [];
|
|
116
|
+
|
|
117
|
+
const http: CorpusHttp = {
|
|
118
|
+
async get<T>(path: string, init?: { params?: Record<string, unknown> }): Promise<T> {
|
|
119
|
+
const params = init?.params ?? {};
|
|
120
|
+
requests.push({ path, params });
|
|
121
|
+
|
|
122
|
+
if (path.includes('relationships')) {
|
|
123
|
+
return { count: 0, next: null, previous: null, results: [] } as T;
|
|
124
|
+
}
|
|
125
|
+
if (path.includes('connections')) {
|
|
126
|
+
return { count: 0, next: null, previous: null, results: [] } as T;
|
|
127
|
+
}
|
|
128
|
+
if (!path.includes('entities')) {
|
|
129
|
+
throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// BOTH SPELLINGS OF THE DETAIL ROUTE, because both were measured to
|
|
133
|
+
// resolve on the live tenant. A fake that insisted on the trailing slash
|
|
134
|
+
// would manufacture evidence for a path change the wire does not ask for.
|
|
135
|
+
const detail = /entities\/([^/?]+)\/?$/.exec(path)?.[1];
|
|
136
|
+
if (detail) {
|
|
137
|
+
if (options.detailStatus) {
|
|
138
|
+
throw Object.assign(new Error(`${path} responded ${options.detailStatus}`), {
|
|
139
|
+
status: options.detailStatus,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const found = rows.find((row) => String(row.id) === decodeURIComponent(detail));
|
|
143
|
+
// ABSENT, NOT REFUSED — `readable_types_q` / `readable_rows_q` narrow the
|
|
144
|
+
// queryset, so a row this reader may not see 404s exactly like one that
|
|
145
|
+
// was never written.
|
|
146
|
+
if (!found) throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
|
|
147
|
+
return found as T;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The LIST. `?type=` narrows; every other key is swept into
|
|
151
|
+
// `ignored_filters` and the page comes back exactly as wide as it was.
|
|
152
|
+
const type = params.type ? String(params.type) : '';
|
|
153
|
+
const matching = type ? rows.filter((row) => row.entity_type === type) : rows;
|
|
154
|
+
const size = Number(params.page_size ?? pageSize) || pageSize;
|
|
155
|
+
const page = Number(params.page ?? 1) || 1;
|
|
156
|
+
const start = (page - 1) * size;
|
|
157
|
+
const applied: string[] = [];
|
|
158
|
+
const ignored: string[] = [];
|
|
159
|
+
for (const key of Object.keys(params)) {
|
|
160
|
+
if (RESERVED.has(key)) continue;
|
|
161
|
+
(HONOURED.has(key) ? applied : ignored).push(key);
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
count: matching.length,
|
|
165
|
+
next: start + size < matching.length ? `?page=${page + 1}` : null,
|
|
166
|
+
previous: page > 1 ? `?page=${page - 1}` : null,
|
|
167
|
+
results: matching.slice(start, start + size),
|
|
168
|
+
applied_filters: applied,
|
|
169
|
+
ignored_filters: ignored,
|
|
170
|
+
} as T;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return { http, requests };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The tenant as it answers a lookup that was expressed as a FILTER.
|
|
179
|
+
*
|
|
180
|
+
* `GET /api/v1/entities/?id=5ab48b58-…` was checked against prod for this epic
|
|
181
|
+
* and returned 200 with count 180 — the whole corpus, unnarrowed, because `id`
|
|
182
|
+
* is not a filter this API has. Any client that reaches the entity list instead
|
|
183
|
+
* of the detail route gets THIS: a page envelope where it expected a row.
|
|
184
|
+
*
|
|
185
|
+
* The contract under test is that such a response is a FAILURE, not a document.
|
|
186
|
+
* Mapping it produces a record with no id and no title that renders as a blank
|
|
187
|
+
* panel — an arbitrary answer wearing a real record's clothes, which is worse
|
|
188
|
+
* than an error because nobody can tell it is wrong.
|
|
189
|
+
*/
|
|
190
|
+
function pageEnvelopeHttp() {
|
|
191
|
+
const { requests } = tenantHttp();
|
|
192
|
+
const http: CorpusHttp = {
|
|
193
|
+
async get<T>(path: string, init?: { params?: Record<string, unknown> }): Promise<T> {
|
|
194
|
+
requests.push({ path, params: init?.params ?? {} });
|
|
195
|
+
if (path.includes('entities')) {
|
|
196
|
+
return {
|
|
197
|
+
count: CORPUS.length,
|
|
198
|
+
next: '?page=2',
|
|
199
|
+
previous: null,
|
|
200
|
+
results: CORPUS.slice(0, 20),
|
|
201
|
+
ignored_filters: ['id'],
|
|
202
|
+
} as T;
|
|
203
|
+
}
|
|
204
|
+
return { count: 0, next: null, previous: null, results: [] } as T;
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
return { http, requests };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function corpusOver(http: CorpusHttp) {
|
|
211
|
+
return createCorpusApi({ http, scope: SCOPE, supportedFilters: [] });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// the client
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
describe('a document lookup returns THAT document', () => {
|
|
219
|
+
it('answers with the record that was asked for, not the first of many', async () => {
|
|
220
|
+
const api = corpusOver(tenantHttp().http);
|
|
221
|
+
const found = await api.getDocument(OPENED);
|
|
222
|
+
expect(found.id).toBe(OPENED);
|
|
223
|
+
expect(found.title).toBe('Tandem <> Obwb quarterly review');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('asks the DETAIL route, and never expresses the id as a filter', async () => {
|
|
227
|
+
// The distinction the whole bead turns on. `?id=` is swept into
|
|
228
|
+
// `ignored_filters` by this API, so a lookup spelled that way is handed the
|
|
229
|
+
// unfiltered corpus and has to guess.
|
|
230
|
+
const { http, requests } = tenantHttp();
|
|
231
|
+
await corpusOver(http).getDocument(OPENED);
|
|
232
|
+
|
|
233
|
+
const lookup = requests.find((request) => request.path.includes(OPENED));
|
|
234
|
+
expect(lookup, 'the id belongs in the PATH').toBeTruthy();
|
|
235
|
+
expect(
|
|
236
|
+
requests.every((request) => !('id' in request.params)),
|
|
237
|
+
'no request may carry the id as a query parameter',
|
|
238
|
+
).toBe(true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('refuses a page envelope instead of fabricating a record from it', async () => {
|
|
242
|
+
// A response that is a LIST is not the record, whatever produced it — an
|
|
243
|
+
// ignored `?id=`, a proxy that dropped the path segment, a deployment whose
|
|
244
|
+
// detail route is a list. Mapping it yields id `undefined` and the title
|
|
245
|
+
// "Untitled unknown record": a blank panel that claims to be the document.
|
|
246
|
+
const api = corpusOver(pageEnvelopeHttp().http);
|
|
247
|
+
await expect(api.getDocument(OPENED)).rejects.toMatchObject({ status: 404 });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('surfaces a record that is genuinely absent as not-found', async () => {
|
|
251
|
+
const api = corpusOver(tenantHttp().http);
|
|
252
|
+
await expect(api.getDocument(ABSENT)).rejects.toMatchObject({ status: 404 });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('never answers one id with a different record', async () => {
|
|
256
|
+
const api = corpusOver(tenantHttp().http);
|
|
257
|
+
const found = await api.getDocument('doc-3');
|
|
258
|
+
expect(found.id).toBe('doc-3');
|
|
259
|
+
await expect(api.getDocument(ABSENT)).rejects.toBeTruthy();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// the screen
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
describe('the Library opens the record it was deep-linked to', () => {
|
|
268
|
+
it('renders a record that is not on the first page', async () => {
|
|
269
|
+
const api = corpusOver(tenantHttp().http);
|
|
270
|
+
render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={OPENED} />);
|
|
271
|
+
|
|
272
|
+
expect(
|
|
273
|
+
await screen.findByRole('region', { name: 'Record detail' }),
|
|
274
|
+
).toHaveTextContent('Tandem <> Obwb quarterly review');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('SAYS SO when the record cannot be found, instead of rendering nothing', async () => {
|
|
278
|
+
// THE REPORTED SYMPTOM. `?doc=<id>` in the address bar, a list on screen,
|
|
279
|
+
// and no record and no explanation — the composer swallowed the failure into
|
|
280
|
+
// `setSelected(null)`. A screen that asked for a record and shows neither the
|
|
281
|
+
// record nor a reason is the one outcome the user cannot act on.
|
|
282
|
+
const api = corpusOver(tenantHttp().http);
|
|
283
|
+
render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={ABSENT} />);
|
|
284
|
+
|
|
285
|
+
const notice = await screen.findByRole('region', { name: 'Record' });
|
|
286
|
+
expect(notice).toHaveTextContent(/could not be found/i);
|
|
287
|
+
expect(notice).toHaveTextContent(new RegExp(ABSENT));
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('calls an expired session an expired session, not a missing record', async () => {
|
|
291
|
+
// "Sign in again" and "that record is gone" send the reader to different
|
|
292
|
+
// places, and only one of them is true at a time.
|
|
293
|
+
const api = corpusOver(tenantHttp({ detailStatus: 401 }).http);
|
|
294
|
+
render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={OPENED} />);
|
|
295
|
+
|
|
296
|
+
const notice = await screen.findByRole('region', { name: 'Record' });
|
|
297
|
+
expect(notice).toHaveTextContent(/session has expired/i);
|
|
298
|
+
expect(notice).not.toHaveTextContent(/could not be found/i);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('calls a refusal a refusal', async () => {
|
|
302
|
+
const api = corpusOver(tenantHttp({ detailStatus: 403 }).http);
|
|
303
|
+
render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={OPENED} />);
|
|
304
|
+
|
|
305
|
+
expect(await screen.findByRole('region', { name: 'Record' })).toHaveTextContent(
|
|
306
|
+
/do not have access/i,
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('lets the reader dismiss the notice and get back to the list', async () => {
|
|
311
|
+
// Without this the `?doc=` in the URL is a dead end: the panel cannot be
|
|
312
|
+
// closed because there is no panel, only a message.
|
|
313
|
+
const api = corpusOver(tenantHttp().http);
|
|
314
|
+
render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={ABSENT} />);
|
|
315
|
+
|
|
316
|
+
const notice = await screen.findByRole('region', { name: 'Record' });
|
|
317
|
+
expect(within(notice).getByRole('button', { name: /close/i })).toBeInTheDocument();
|
|
318
|
+
});
|
|
319
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE LIBRARY'S SOURCE STRIP MUST LIST THE SOURCES A MEMBER ACTUALLY HAS
|
|
3
|
+
* (bd startsim-pg8na RED / startsim-bu04n GREEN).
|
|
4
|
+
*
|
|
5
|
+
* REPORTED ON THE LIVE TANDEM TENANT, 2026-08-26: the strip showed Granola
|
|
6
|
+
* ("Failing", 20 syncs in a row failed) and n8n, while the records listed below
|
|
7
|
+
* it were Fathom and Granola documents, and the member's own Fathom and Dropbox
|
|
8
|
+
* connections never appeared at all.
|
|
9
|
+
*
|
|
10
|
+
* WHERE THE ROWS COME FROM, which the original diagnosis got wrong and which
|
|
11
|
+
* decides what may be tested here. There is no tenant-local connections table.
|
|
12
|
+
* `tenant-starter/config/urls.py` routes `api/v1/connections/` to
|
|
13
|
+
* `ConnectionsView`, whose only job is to forward the caller's already-verified
|
|
14
|
+
* bearer to CENTRAL's `/api/v1/subtree/connections/` and return the page
|
|
15
|
+
* envelope untouched ("proxied from central so the call stays same-origin",
|
|
16
|
+
* bd startsim-768w.38.19). So `listSources()` is already reading central — it is
|
|
17
|
+
* reading central's SHARED-only surface, and the two facts line up exactly:
|
|
18
|
+
*
|
|
19
|
+
* TENANT GET /api/v1/connections/ -> granola, n8n
|
|
20
|
+
* CENTRAL Connection rows for tandem -> dropbox(owner qosha),
|
|
21
|
+
* fathom(owner qosha),
|
|
22
|
+
* granola(SHARED), n8n(SHARED)
|
|
23
|
+
*
|
|
24
|
+
* `SubtreeConnectionsView` resolves through `Connection.objects.for_scope(...)`,
|
|
25
|
+
* which is `owner IS NULL`. Granola and n8n are not stale junk: they are the
|
|
26
|
+
* foundry's real shared connections, and Granola really is failing. The two
|
|
27
|
+
* missing cards are missing because the server was never asked for them.
|
|
28
|
+
*
|
|
29
|
+
* THE DIVISION OF LABOUR THIS FILE ASSUMES.
|
|
30
|
+
*
|
|
31
|
+
* Server: a member's OWN rows reach this endpoint. That is a union of
|
|
32
|
+
* `for_scope(orgs) | for_owner(orgs, request.user)` in central — the
|
|
33
|
+
* remedy `SubtreeConnectionsView` names in its own source, bounded to
|
|
34
|
+
* the requesting user so the disclosure bd startsim-768w.22.25.11
|
|
35
|
+
* closed stays closed. It cannot be done from this package, and it
|
|
36
|
+
* must not be done by merging in `/api/v1/connections/me/`: that
|
|
37
|
+
* surface's serializer carries `last_checked_at` and NOTHING ELSE of
|
|
38
|
+
* the three freshness facts (no `last_synced_at`, no
|
|
39
|
+
* `last_attempt_at`, no `consecutive_failures` — see
|
|
40
|
+
* `ConnectionSerializer.Meta.fields`), so every personal card built
|
|
41
|
+
* from it would report a verified credential as delivered data. That
|
|
42
|
+
* is precisely the lie bd startsim-1cow removed.
|
|
43
|
+
*
|
|
44
|
+
* Client: what arrives is rendered honestly. A connection carries an owner or
|
|
45
|
+
* it does not and the strip treats both alike; a connection the member
|
|
46
|
+
* DISCONNECTED is not a source of the corpus and must stop appearing
|
|
47
|
+
* as one.
|
|
48
|
+
*
|
|
49
|
+
* The second half is the part that fails today, and it is the "legacy ones
|
|
50
|
+
* masquerading as sources" the report describes: `disconnect()` deliberately
|
|
51
|
+
* keeps the card as `status: 'disabled'` ("DISCONNECT, not delete … the card
|
|
52
|
+
* survives as `disabled`, so the record that this vendor was connected is not
|
|
53
|
+
* thrown away"), and `listSources()` has no case for that status, so a vendor
|
|
54
|
+
* nobody is connected to any more falls through and renders next to the live
|
|
55
|
+
* ones. Not a denylist of connector keys — `status` is data the server writes.
|
|
56
|
+
*/
|
|
57
|
+
import { describe, expect, it } from 'vitest';
|
|
58
|
+
import { render, screen, within } from '@testing-library/react';
|
|
59
|
+
|
|
60
|
+
import { LibraryPage } from '../components/pages/LibraryPage';
|
|
61
|
+
import { createCorpusApi } from '../corpus/client';
|
|
62
|
+
import type { CorpusHttp } from '../corpus/client';
|
|
63
|
+
import { FIXTURE_ORG } from '../corpus/fixtures';
|
|
64
|
+
|
|
65
|
+
const SCOPE = { orgId: FIXTURE_ORG };
|
|
66
|
+
const EMPTY_PAGE = { count: 0, next: null, previous: null, results: [] };
|
|
67
|
+
|
|
68
|
+
const NOW = Date.now();
|
|
69
|
+
const ago = (ms: number) => new Date(NOW - ms).toISOString();
|
|
70
|
+
const AN_HOUR_AGO = ago(60 * 60_000);
|
|
71
|
+
const TEN_MINUTES_AGO = ago(10 * 60_000);
|
|
72
|
+
|
|
73
|
+
/** Every path this adapter asked for, so a widening read cannot pass unnoticed. */
|
|
74
|
+
interface Recorder {
|
|
75
|
+
http: CorpusHttp;
|
|
76
|
+
calls: Array<{ path: string; params?: Record<string, unknown> }>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The WIRE, not the contract — rows go in exactly as central serialises them.
|
|
81
|
+
* Same convention as `corpus-freshness.test.tsx`, for the same reason: a fixture
|
|
82
|
+
* must never be more generous than the server that feeds it.
|
|
83
|
+
*/
|
|
84
|
+
function recorderOver(rows: Array<Record<string, unknown>>): Recorder {
|
|
85
|
+
const calls: Array<{ path: string; params?: Record<string, unknown> }> = [];
|
|
86
|
+
return {
|
|
87
|
+
calls,
|
|
88
|
+
http: {
|
|
89
|
+
async get<T>(path: string, options?: { params?: Record<string, unknown> }): Promise<T> {
|
|
90
|
+
calls.push({ path, params: options?.params });
|
|
91
|
+
if (path.includes('connections')) {
|
|
92
|
+
return { count: rows.length, next: null, previous: null, results: rows } as T;
|
|
93
|
+
}
|
|
94
|
+
return EMPTY_PAGE as T;
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sourcesFrom(rows: Array<Record<string, unknown>>) {
|
|
101
|
+
const { http } = recorderOver(rows);
|
|
102
|
+
return createCorpusApi({ http, scope: SCOPE }).listSources();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The foundry's shared Granola: real, connected, and genuinely failing. */
|
|
106
|
+
const SHARED_GRANOLA = {
|
|
107
|
+
id: 'shared-granola',
|
|
108
|
+
connector_key: 'granola',
|
|
109
|
+
connector_name: 'Granola',
|
|
110
|
+
status: 'connected',
|
|
111
|
+
check_error: '',
|
|
112
|
+
last_synced_at: null,
|
|
113
|
+
last_attempt_at: TEN_MINUTES_AGO,
|
|
114
|
+
last_checked_at: null,
|
|
115
|
+
consecutive_failures: 20,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The member's OWN Fathom, as central serialises an owned row once the subtree
|
|
120
|
+
* read includes them. `owner` is the only structural difference, and the strip
|
|
121
|
+
* must not care: whose account it is decides who may SEE the row, server-side.
|
|
122
|
+
*/
|
|
123
|
+
const MY_FATHOM = {
|
|
124
|
+
id: 'my-fathom',
|
|
125
|
+
connector_key: 'fathom',
|
|
126
|
+
connector_name: 'Fathom',
|
|
127
|
+
owner: 'qosha@debugg.ai',
|
|
128
|
+
status: 'connected',
|
|
129
|
+
check_error: '',
|
|
130
|
+
last_synced_at: AN_HOUR_AGO,
|
|
131
|
+
last_attempt_at: AN_HOUR_AGO,
|
|
132
|
+
last_checked_at: AN_HOUR_AGO,
|
|
133
|
+
consecutive_failures: 0,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** A vendor the member has DISCONNECTED. The card survives; the source does not. */
|
|
137
|
+
const DISCONNECTED_DROPBOX = {
|
|
138
|
+
id: 'old-dropbox',
|
|
139
|
+
connector_key: 'dropbox',
|
|
140
|
+
connector_name: 'Dropbox',
|
|
141
|
+
owner: 'qosha@debugg.ai',
|
|
142
|
+
status: 'disabled',
|
|
143
|
+
check_error: '',
|
|
144
|
+
last_synced_at: null,
|
|
145
|
+
last_attempt_at: null,
|
|
146
|
+
last_checked_at: null,
|
|
147
|
+
consecutive_failures: 0,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
describe('listSources — a personal connection is a source like any other', () => {
|
|
151
|
+
it('lists the member’s own connection alongside the foundry’s shared ones', async () => {
|
|
152
|
+
const sources = await sourcesFrom([SHARED_GRANOLA, MY_FATHOM]);
|
|
153
|
+
|
|
154
|
+
expect(sources.map((source) => source.source).sort()).toEqual(['fathom', 'granola']);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('keeps a personal connection’s freshness intact rather than flattening it', async () => {
|
|
158
|
+
const [fathom] = await sourcesFrom([MY_FATHOM]);
|
|
159
|
+
|
|
160
|
+
// The whole reason a merge from `/api/v1/connections/me/` is refused: that
|
|
161
|
+
// payload has no `last_synced_at` at all, so a card built from it could only
|
|
162
|
+
// ever say "nothing yet" over data that arrived an hour ago.
|
|
163
|
+
expect(fathom.lastSyncedAt).toBe(AN_HOUR_AGO);
|
|
164
|
+
expect(fathom.status).toBe('ok');
|
|
165
|
+
expect(fathom.label).toBe('Fathom');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('reads one connections endpoint and does not widen the read to reach them', async () => {
|
|
169
|
+
// A single source of truth, stated as a test so a future "just also fetch
|
|
170
|
+
// everybody's" cannot be added quietly. The bound that actually keeps
|
|
171
|
+
// bd startsim-768w.22.25.11 closed is server-side; this pins that the client
|
|
172
|
+
// never asks for more than the member-scoped page it is handed.
|
|
173
|
+
const recorder = recorderOver([SHARED_GRANOLA, MY_FATHOM]);
|
|
174
|
+
await createCorpusApi({ http: recorder.http, scope: SCOPE }).listSources();
|
|
175
|
+
|
|
176
|
+
const connectionReads = recorder.calls.filter((call) => call.path.includes('connections'));
|
|
177
|
+
expect(connectionReads).toHaveLength(1);
|
|
178
|
+
expect(connectionReads[0].path).toBe('api/v1/connections');
|
|
179
|
+
// No owner, no user, no "all" — nothing that names a principal other than
|
|
180
|
+
// the bearer the proxy already forwarded.
|
|
181
|
+
expect(connectionReads[0].params ?? {}).toEqual({});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('listSources — a disconnected vendor is not a source', () => {
|
|
186
|
+
it('drops a connection the member has disconnected', async () => {
|
|
187
|
+
const sources = await sourcesFrom([SHARED_GRANOLA, MY_FATHOM, DISCONNECTED_DROPBOX]);
|
|
188
|
+
|
|
189
|
+
expect(sources.map((source) => source.source).sort()).toEqual(['fathom', 'granola']);
|
|
190
|
+
expect(sources.some((source) => source.source === 'dropbox')).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('never renders a disconnected vendor as healthy', async () => {
|
|
194
|
+
const [source] = await sourcesFrom([DISCONNECTED_DROPBOX]);
|
|
195
|
+
|
|
196
|
+
// If it is listed at all it must not read as a working source. Today it
|
|
197
|
+
// falls through every branch and comes back `never` — "Connected, but
|
|
198
|
+
// nothing has arrived yet" about an account that is not connected.
|
|
199
|
+
expect(source).toBeUndefined();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('keeps a broken-but-live connection, which is a different thing entirely', async () => {
|
|
203
|
+
// `needs_reauth` and `error` are still connected accounts with a credential
|
|
204
|
+
// on file and a member who can fix them. Only the lifecycle-ended row goes.
|
|
205
|
+
const sources = await sourcesFrom([
|
|
206
|
+
{ ...MY_FATHOM, status: 'needs_reauth', check_error: 'The vendor rejected this key (401).' },
|
|
207
|
+
SHARED_GRANOLA,
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
expect(sources.map((source) => source.source).sort()).toEqual(['fathom', 'granola']);
|
|
211
|
+
expect(sources.find((source) => source.source === 'fathom')?.status).toBe('error');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('the Library strip, rendered', () => {
|
|
216
|
+
it('draws a card for the member’s own source and none for the disconnected one', async () => {
|
|
217
|
+
const { http } = recorderOver([SHARED_GRANOLA, MY_FATHOM, DISCONNECTED_DROPBOX]);
|
|
218
|
+
const api = createCorpusApi({ http, scope: SCOPE });
|
|
219
|
+
|
|
220
|
+
render(<LibraryPage api={api} scope={SCOPE} />);
|
|
221
|
+
|
|
222
|
+
// Scoped to the strip itself: `listSources` also feeds the source filter's
|
|
223
|
+
// options, so an unscoped query would match the same vendor twice and say
|
|
224
|
+
// nothing about which surface it appeared on.
|
|
225
|
+
const strip = within(await screen.findByRole('region', { name: 'Source freshness' }));
|
|
226
|
+
expect(strip.getByText('Fathom')).toBeInTheDocument();
|
|
227
|
+
expect(strip.getByText('Granola')).toBeInTheDocument();
|
|
228
|
+
expect(strip.queryByText('Dropbox')).not.toBeInTheDocument();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('does not blame a disconnected vendor in the empty state', async () => {
|
|
232
|
+
// `EmptyState` names every source whose status is `error`. A vendor nobody
|
|
233
|
+
// is connected to must not be reported as "failing to sync" — the action
|
|
234
|
+
// that sentence asks for (go fix the connection) is the wrong action.
|
|
235
|
+
const { http } = recorderOver([DISCONNECTED_DROPBOX]);
|
|
236
|
+
const api = createCorpusApi({ http, scope: SCOPE });
|
|
237
|
+
|
|
238
|
+
render(<LibraryPage api={api} scope={SCOPE} />);
|
|
239
|
+
|
|
240
|
+
expect(await screen.findByText(/Nothing has been pulled in yet/i)).toBeInTheDocument();
|
|
241
|
+
expect(screen.queryByText(/Dropbox/)).not.toBeInTheDocument();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -85,6 +85,29 @@ function readFailureMessage(err: unknown): string {
|
|
|
85
85
|
return (err as { message?: string })?.message || 'Could not load the library.';
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Why the record the screen was asked to open is not on the screen
|
|
90
|
+
* (bd startsim-2lt8y).
|
|
91
|
+
*
|
|
92
|
+
* FOUR ANSWERS, NOT ONE, and the branch is the point: "sign in again", "you may
|
|
93
|
+
* not see this", "that record is not here" and "something went wrong" send the
|
|
94
|
+
* reader to four different places, and only one of them is true at a time.
|
|
95
|
+
* Calling an expired session a missing record is a different lie from the one
|
|
96
|
+
* this bead is fixing, not a smaller version of it.
|
|
97
|
+
*
|
|
98
|
+
* The id is NAMED because it came out of the address bar. A citation or a
|
|
99
|
+
* bookmark that no longer resolves is worth being able to quote back to
|
|
100
|
+
* somebody, and "a record" is not quotable.
|
|
101
|
+
*/
|
|
102
|
+
function openFailureMessage(err: unknown, id: string): string {
|
|
103
|
+
const status = (err as { status?: number })?.status;
|
|
104
|
+
if (status === 404) {
|
|
105
|
+
return `That record could not be found in this workspace (${id}). It may have been removed, or it may not be one you can see.`;
|
|
106
|
+
}
|
|
107
|
+
if (status === 401 || status === 403) return readFailureMessage(err);
|
|
108
|
+
return (err as { message?: string })?.message || `That record could not be opened (${id}).`;
|
|
109
|
+
}
|
|
110
|
+
|
|
88
111
|
function formatDate(value: string | null | undefined): string {
|
|
89
112
|
if (!value) return 'No date';
|
|
90
113
|
const at = Date.parse(value);
|
|
@@ -160,6 +183,13 @@ export function LibraryPage({
|
|
|
160
183
|
const [parties, setParties] = React.useState<Party[]>([]);
|
|
161
184
|
const [selectedId, setSelectedId] = React.useState<string | null>(initialDocumentId ?? null);
|
|
162
185
|
const [selected, setSelected] = React.useState<SourceDocument | null>(null);
|
|
186
|
+
/**
|
|
187
|
+
* Why the open record is not open. Held separately from `loadError`, which is
|
|
188
|
+
* about the LIST: a Library that loaded fine and a citation that no longer
|
|
189
|
+
* resolves are different facts, and blanking the list for the second one would
|
|
190
|
+
* be a worse answer than the silence this replaces.
|
|
191
|
+
*/
|
|
192
|
+
const [selectError, setSelectError] = React.useState('');
|
|
163
193
|
const [loading, setLoading] = React.useState(true);
|
|
164
194
|
const [loadError, setLoadError] = React.useState('');
|
|
165
195
|
/**
|
|
@@ -216,18 +246,22 @@ export function LibraryPage({
|
|
|
216
246
|
}, [api, filters, scope]);
|
|
217
247
|
|
|
218
248
|
React.useEffect(() => {
|
|
219
|
-
if (!selectedId) { setSelected(null); return; }
|
|
249
|
+
if (!selectedId) { setSelected(null); setSelectError(''); return; }
|
|
220
250
|
let live = true;
|
|
221
251
|
void (async () => {
|
|
222
252
|
// Prefer the row already on screen; fall back to a fetch so a citation
|
|
223
253
|
// deep-link opens a record that is not on the current page.
|
|
224
254
|
const onPage = documents.find((document) => document.id === selectedId);
|
|
225
|
-
if (onPage) { setSelected(onPage); return; }
|
|
255
|
+
if (onPage) { setSelected(onPage); setSelectError(''); return; }
|
|
226
256
|
try {
|
|
227
257
|
const fetched = await api.getDocument(selectedId);
|
|
228
|
-
if (live) setSelected(fetched);
|
|
229
|
-
} catch {
|
|
230
|
-
|
|
258
|
+
if (live) { setSelected(fetched); setSelectError(''); }
|
|
259
|
+
} catch (err) {
|
|
260
|
+
// THE REPORTED BUG (bd startsim-4s4um). This was `setSelected(null)` and
|
|
261
|
+
// nothing else, so `?doc=<id>` in the address bar produced a Library
|
|
262
|
+
// with no record and no explanation — the one outcome a reader cannot
|
|
263
|
+
// act on, and indistinguishable from a page that simply ignored them.
|
|
264
|
+
if (live) { setSelected(null); setSelectError(openFailureMessage(err, selectedId)); }
|
|
231
265
|
}
|
|
232
266
|
})();
|
|
233
267
|
return () => { live = false; };
|
|
@@ -235,6 +269,7 @@ export function LibraryPage({
|
|
|
235
269
|
|
|
236
270
|
const select = React.useCallback((id: string | null) => {
|
|
237
271
|
setSelectedId(id);
|
|
272
|
+
if (!id) setSelectError('');
|
|
238
273
|
onSelectDocument?.(id);
|
|
239
274
|
}, [onSelectDocument]);
|
|
240
275
|
|
|
@@ -459,6 +494,16 @@ export function LibraryPage({
|
|
|
459
494
|
onFilterByParty={(partyId) => update({ partyId })}
|
|
460
495
|
/>
|
|
461
496
|
) : null}
|
|
497
|
+
|
|
498
|
+
{/* Asked for, and not shown — said out loud rather than left blank. */}
|
|
499
|
+
{!selected && selectError ? (
|
|
500
|
+
<section className={cn.card} aria-label="Record">
|
|
501
|
+
<div className="flex items-start justify-between gap-4">
|
|
502
|
+
<p className={cn.warning} role="alert">{selectError}</p>
|
|
503
|
+
<button type="button" className={cn.button} onClick={() => select(null)}>Close</button>
|
|
504
|
+
</div>
|
|
505
|
+
</section>
|
|
506
|
+
) : null}
|
|
462
507
|
</div>
|
|
463
508
|
);
|
|
464
509
|
}
|
package/src/corpus/client.ts
CHANGED
|
@@ -292,6 +292,42 @@ function at(value: unknown): string | null {
|
|
|
292
292
|
return typeof value === 'string' && value.trim() ? value : null;
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Connection lifecycle states in which the account is NO LONGER CONNECTED
|
|
297
|
+
* (bd startsim-bu04n).
|
|
298
|
+
*
|
|
299
|
+
* `Connection.Status` is `connected | needs_reauth | error | disabled`, and only
|
|
300
|
+
* the last one has ended: the member disconnected it. Central's member surface
|
|
301
|
+
* documents what that leaves behind — "DISCONNECT, not delete. One transaction
|
|
302
|
+
* stops the scheduled fetches, revokes every token issued for the account and
|
|
303
|
+
* deletes the stored credential; the card survives as `disabled`, so the record
|
|
304
|
+
* that this vendor was connected is not thrown away."
|
|
305
|
+
*
|
|
306
|
+
* The card surviving is right for the connections screen, where a member manages
|
|
307
|
+
* it and may reconnect. It is wrong for the Library's source strip, whose whole
|
|
308
|
+
* question is "where is my corpus coming from, and is it current". A vendor with
|
|
309
|
+
* no credential and no schedule is not coming from anywhere, and it used to fall
|
|
310
|
+
* through every branch below to `never` — "Connected, but nothing has arrived
|
|
311
|
+
* yet" printed about an account that is not connected.
|
|
312
|
+
*
|
|
313
|
+
* A STATUS, NOT A VENDOR. This reads the lifecycle field central writes, so a
|
|
314
|
+
* connector added tomorrow is handled with no code change here. A list of
|
|
315
|
+
* connector keys would be exactly the per-vendor branch the DB-driven catalog
|
|
316
|
+
* exists to prevent, and the reported symptom ("it shows old, disconnected
|
|
317
|
+
* ones") is precisely the symptom such a list would paper over.
|
|
318
|
+
*
|
|
319
|
+
* AN UNRECOGNISED STATUS IS KEPT, deliberately. `needs_reauth` and `error` are
|
|
320
|
+
* live accounts with a credential on file and a member who can fix them; the
|
|
321
|
+
* failure mode of hiding one of those — a real source silently absent from the
|
|
322
|
+
* strip — is worse than the failure mode of showing a status this build has not
|
|
323
|
+
* heard of yet. A new terminal state is added here on purpose, not by default.
|
|
324
|
+
*/
|
|
325
|
+
const RETIRED_CONNECTION_STATUSES: ReadonlySet<string> = new Set(['disabled']);
|
|
326
|
+
|
|
327
|
+
function isALiveSource(connection: Record<string, unknown>): boolean {
|
|
328
|
+
return !RETIRED_CONNECTION_STATUSES.has(String(connection.status ?? ''));
|
|
329
|
+
}
|
|
330
|
+
|
|
295
331
|
/** Rows written in parallel. Small enough not to look like a burst to the tenant. */
|
|
296
332
|
const WRITE_CONCURRENCY = 6;
|
|
297
333
|
|
|
@@ -320,6 +356,38 @@ function appliedFilters(
|
|
|
320
356
|
return requested.filter((key) => supported.includes(key));
|
|
321
357
|
}
|
|
322
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Is this response the ROW that was asked for, or something else wearing a 200?
|
|
361
|
+
*
|
|
362
|
+
* Three things are refused, and each of them has been seen answering a
|
|
363
|
+
* single-record request somewhere: a DRF page (what an ignored `?id=` returns —
|
|
364
|
+
* verified on the live tenant, 200 with count 180), a body with no id at all,
|
|
365
|
+
* and a row whose id is somebody else's.
|
|
366
|
+
*/
|
|
367
|
+
function isRecord(value: unknown, id: string): value is TenantEntity {
|
|
368
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
369
|
+
// A page is the shape a LIST answers with. It is never one record, whatever
|
|
370
|
+
// its count happens to be.
|
|
371
|
+
if (Array.isArray((value as { results?: unknown }).results)) return false;
|
|
372
|
+
const own = (value as TenantEntity).id;
|
|
373
|
+
if (own === undefined || own === null || String(own).trim() === '') return false;
|
|
374
|
+
// Case-insensitively, because a UUID has two legal spellings and the server
|
|
375
|
+
// answers in its own. Anything else different is a DIFFERENT RECORD, which is
|
|
376
|
+
// the entire reason this function exists.
|
|
377
|
+
return String(own).trim().toLowerCase() === String(id).trim().toLowerCase();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The tenant's own word for a row that is not there.
|
|
382
|
+
*
|
|
383
|
+
* 404 and not a bespoke error, because the tenant's read gate makes a row this
|
|
384
|
+
* principal may not see ABSENT rather than refused — so callers get one status
|
|
385
|
+
* to branch on, and "not found" is already the honest word for both.
|
|
386
|
+
*/
|
|
387
|
+
function notFound(id: string): Error & { status: number } {
|
|
388
|
+
return Object.assign(new Error(`No record ${id} in this workspace.`), { status: 404 });
|
|
389
|
+
}
|
|
390
|
+
|
|
323
391
|
export function createCorpusApi(
|
|
324
392
|
options: CreateCorpusApiOptions,
|
|
325
393
|
): CorpusApi & CommitmentApi & ExtractionRunStore {
|
|
@@ -423,11 +491,34 @@ export function createCorpusApi(
|
|
|
423
491
|
};
|
|
424
492
|
},
|
|
425
493
|
|
|
494
|
+
/**
|
|
495
|
+
* ONE record, by the DETAIL ROUTE — never by a filter (bd startsim-2lt8y).
|
|
496
|
+
*
|
|
497
|
+
* `/api/v1/entities/<id>/` is a real route on the tenant and it answers the
|
|
498
|
+
* one row. The entity LIST has no `id` filter: `EntityQuery._note_unknown`
|
|
499
|
+
* sweeps every unrecognised parameter into `ignored_filters` and returns the
|
|
500
|
+
* page exactly as wide as it was, so `?id=<uuid>` comes back 200 with the
|
|
501
|
+
* whole corpus. A lookup spelled that way cannot fail — it just answers with
|
|
502
|
+
* somebody else's record.
|
|
503
|
+
*
|
|
504
|
+
* WHICH IS WHY THE RESPONSE IS CHECKED. Asking the right route is not the
|
|
505
|
+
* same as being answered by it: a proxy that drops the path segment, a
|
|
506
|
+
* deployment whose detail route lists, an `?id=` that got ignored upstream —
|
|
507
|
+
* all hand back a DRF page where a row was expected, and `toSourceDocument`
|
|
508
|
+
* will map it without complaint into a document with the id `undefined` and
|
|
509
|
+
* the title "Untitled unknown record". That renders as a blank panel that
|
|
510
|
+
* claims to be the record, which is worse than an error because nobody
|
|
511
|
+
* downstream can tell it is wrong. So a response that is not the row that
|
|
512
|
+
* was asked for is a 404, reported the same way the tenant reports an absent
|
|
513
|
+
* row — the read gate makes a forbidden row ABSENT rather than refused, so
|
|
514
|
+
* "not found" is already the honest word for both.
|
|
515
|
+
*/
|
|
426
516
|
async getDocument(id) {
|
|
427
517
|
const [entity, byRecord] = await Promise.all([
|
|
428
518
|
http.get<TenantEntity>(`${entities}/${encodeURIComponent(id)}`),
|
|
429
519
|
participantGraph(),
|
|
430
520
|
]);
|
|
521
|
+
if (!isRecord(entity, id)) throw notFound(id);
|
|
431
522
|
return toSourceDocument(entity, scope, sourceLabels, byRecord.get(String(entity.id)) ?? []);
|
|
432
523
|
},
|
|
433
524
|
|
|
@@ -446,14 +537,6 @@ export function createCorpusApi(
|
|
|
446
537
|
};
|
|
447
538
|
},
|
|
448
539
|
|
|
449
|
-
/**
|
|
450
|
-
* Freshness per SOURCE, assembled from the connections the tenant has.
|
|
451
|
-
*
|
|
452
|
-
* It reads the connections API rather than the documents, because "Dropbox
|
|
453
|
-
* has not synced since Tuesday" is a true and useful statement even when
|
|
454
|
-
* Dropbox has contributed zero documents — and a count derived from the
|
|
455
|
-
* corpus alone can never say it.
|
|
456
|
-
*/
|
|
457
540
|
/**
|
|
458
541
|
* The tracking surface's read (bd startsim-768w.38.25).
|
|
459
542
|
*
|
|
@@ -557,6 +640,41 @@ export function createCorpusApi(
|
|
|
557
640
|
return durable as ExtractionRun;
|
|
558
641
|
},
|
|
559
642
|
|
|
643
|
+
/**
|
|
644
|
+
* Freshness per SOURCE, assembled from the connections behind this workspace.
|
|
645
|
+
*
|
|
646
|
+
* It reads the connections API rather than the documents, because "Dropbox
|
|
647
|
+
* has not synced since Tuesday" is a true and useful statement even when
|
|
648
|
+
* Dropbox has contributed zero documents — and a count derived from the
|
|
649
|
+
* corpus alone can never say it.
|
|
650
|
+
*
|
|
651
|
+
* WHERE THE ROWS ACTUALLY COME FROM (bd startsim-bu04n), because the name of
|
|
652
|
+
* the default path invites the wrong guess. `api/v1/connections` on a tenant
|
|
653
|
+
* fork is NOT a tenant table: `tenant-starter`'s `ConnectionsView` forwards
|
|
654
|
+
* the caller's already-verified bearer to central's
|
|
655
|
+
* `/api/v1/subtree/connections/` and returns the page envelope untouched
|
|
656
|
+
* (bd startsim-768w.38.19). This read is a central read wearing a
|
|
657
|
+
* same-origin path, which is why every field below is central's.
|
|
658
|
+
*
|
|
659
|
+
* WHICH CONNECTIONS THAT SURFACE HANDS BACK is central's decision and not
|
|
660
|
+
* this adapter's. `SubtreeConnectionsView` resolves through
|
|
661
|
+
* `Connection.objects.for_scope(...)` — the foundry's SHARED rows — and a
|
|
662
|
+
* member's own vendor accounts join the page only when central unions
|
|
663
|
+
* `for_owner(orgs, request.user)` in, bounded to the requesting user. That
|
|
664
|
+
* bound is what keeps a colleague's personal connection off this strip
|
|
665
|
+
* (bd startsim-768w.22.25.11), and it is the reason this adapter must not
|
|
666
|
+
* reach for `/api/v1/connections/me/` to fill the gap itself: besides
|
|
667
|
+
* splitting the answer across two servers, that serializer carries
|
|
668
|
+
* `last_checked_at` and NONE of the other freshness facts, so every personal
|
|
669
|
+
* card built from it would report a verified credential as delivered data —
|
|
670
|
+
* exactly the lie bd startsim-1cow removed. Rows are read from one endpoint,
|
|
671
|
+
* as handed.
|
|
672
|
+
*
|
|
673
|
+
* `documentCount` is whatever the connections payload says and is therefore
|
|
674
|
+
* `0` today: no connections endpoint sends `document_count`, and a per-source
|
|
675
|
+
* count from the corpus needs the `?source=` filter bd startsim-768w.38.12
|
|
676
|
+
* is still open on. Tracked as bd startsim-o0o6o.1.
|
|
677
|
+
*/
|
|
560
678
|
async listSources(): Promise<SourceFreshness[]> {
|
|
561
679
|
const [connectionPage, documentPage] = await Promise.all([
|
|
562
680
|
http
|
|
@@ -570,7 +688,7 @@ export function createCorpusApi(
|
|
|
570
688
|
void documentPage; // total count is per-source below; kept for the empty-corpus case
|
|
571
689
|
|
|
572
690
|
const now = Date.now();
|
|
573
|
-
return (connectionPage.results ?? []).map((connection) => {
|
|
691
|
+
return (connectionPage.results ?? []).filter(isALiveSource).map((connection) => {
|
|
574
692
|
const key = String(connection.connector_key ?? connection.connectorKey ?? 'unknown');
|
|
575
693
|
// THREE FACTS, READ SEPARATELY (bd startsim-1cow). This was one `??`
|
|
576
694
|
// chain ending in `last_checked_at`, and central's own view says of that
|