@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.27",
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
+ });
@@ -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