@startsimpli/integrations 0.2.27 → 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,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
|
+
});
|
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
|
|
|
@@ -501,14 +537,6 @@ export function createCorpusApi(
|
|
|
501
537
|
};
|
|
502
538
|
},
|
|
503
539
|
|
|
504
|
-
/**
|
|
505
|
-
* Freshness per SOURCE, assembled from the connections the tenant has.
|
|
506
|
-
*
|
|
507
|
-
* It reads the connections API rather than the documents, because "Dropbox
|
|
508
|
-
* has not synced since Tuesday" is a true and useful statement even when
|
|
509
|
-
* Dropbox has contributed zero documents — and a count derived from the
|
|
510
|
-
* corpus alone can never say it.
|
|
511
|
-
*/
|
|
512
540
|
/**
|
|
513
541
|
* The tracking surface's read (bd startsim-768w.38.25).
|
|
514
542
|
*
|
|
@@ -612,6 +640,41 @@ export function createCorpusApi(
|
|
|
612
640
|
return durable as ExtractionRun;
|
|
613
641
|
},
|
|
614
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
|
+
*/
|
|
615
678
|
async listSources(): Promise<SourceFreshness[]> {
|
|
616
679
|
const [connectionPage, documentPage] = await Promise.all([
|
|
617
680
|
http
|
|
@@ -625,7 +688,7 @@ export function createCorpusApi(
|
|
|
625
688
|
void documentPage; // total count is per-source below; kept for the empty-corpus case
|
|
626
689
|
|
|
627
690
|
const now = Date.now();
|
|
628
|
-
return (connectionPage.results ?? []).map((connection) => {
|
|
691
|
+
return (connectionPage.results ?? []).filter(isALiveSource).map((connection) => {
|
|
629
692
|
const key = String(connection.connector_key ?? connection.connectorKey ?? 'unknown');
|
|
630
693
|
// THREE FACTS, READ SEPARATELY (bd startsim-1cow). This was one `??`
|
|
631
694
|
// chain ending in `last_checked_at`, and central's own view says of that
|