@startsimpli/integrations 0.2.32 → 0.2.34
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__/connection-activity.test.tsx +160 -0
- package/src/__tests__/corpus-narrowed-reads.test.ts +337 -0
- package/src/__tests__/corpus-storage.test.ts +20 -9
- package/src/__tests__/library-failure-reason.test.tsx +178 -0
- package/src/components/pages/ConnectionsPage.tsx +174 -68
- package/src/corpus/client.ts +221 -49
- package/src/corpus/entities.ts +41 -0
- package/src/corpus/fixtures.ts +102 -9
- package/src/corpus/runs.ts +10 -1
- package/src/corpus/types.ts +28 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.34",
|
|
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,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CONNECTION CARD SAYS WHY A RUN FAILED (bd startsim-ei8gm.2, part 2).
|
|
3
|
+
*
|
|
4
|
+
* `ConnectionRun.error_message` has been DECLARED in this package's own types
|
|
5
|
+
* for as long as the endpoint has existed, and no component in
|
|
6
|
+
* `packages/integrations` or `apps/foundry` has ever read it. `connectionRuns`
|
|
7
|
+
* is likewise required on both api interfaces and called by nothing. So the
|
|
8
|
+
* activity endpoint that `#375` just taught to answer a real reason for source
|
|
9
|
+
* runs has no reader at all: a failed run does not render as a status word — it
|
|
10
|
+
* does not render.
|
|
11
|
+
*
|
|
12
|
+
* ONE COMPOSER, TWO SURFACES. `ConnectionsPage` is mounted by the control plane
|
|
13
|
+
* (`apps/foundry`'s `<ConnectionsPage api={api.foundry} foundrySlug={slug} />`)
|
|
14
|
+
* and by a tenant member's own screen in `personal` mode. The disclosure is
|
|
15
|
+
* gated on the VERB EXISTING — the rule the file already applies to Workflows
|
|
16
|
+
* and Settings — so an app pinned to an older `@startsimpli/api` degrades to no
|
|
17
|
+
* control rather than to a button whose handler is `undefined`.
|
|
18
|
+
*
|
|
19
|
+
* NOTHING IS SCRUBBED HERE. The text arrives already redacted against that
|
|
20
|
+
* connection's stored credential plus the platform app_secret
|
|
21
|
+
* (`apps/connectors/services/surface.py::_reason_reader` ->
|
|
22
|
+
* `dispatch.redaction_list`), and a run whose reason could not be proven clean
|
|
23
|
+
* arrives as a sentence saying so. A second redaction path in the browser would
|
|
24
|
+
* be a second thing to get wrong, and the browser does not hold the secrets to
|
|
25
|
+
* do it with.
|
|
26
|
+
*/
|
|
27
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
28
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
29
|
+
import userEvent from '@testing-library/user-event';
|
|
30
|
+
|
|
31
|
+
import { ConnectionsPage } from '../components/pages/ConnectionsPage';
|
|
32
|
+
import type { ConnectionsApi, ConnectionRun } from '../components/pages/types';
|
|
33
|
+
|
|
34
|
+
const CATALOG = {
|
|
35
|
+
results: [
|
|
36
|
+
{
|
|
37
|
+
key: 'granola', name: 'Granola', description: 'Meeting notes',
|
|
38
|
+
logo_url: '', docs_url: '', capabilities: ['poll'], auth_mode: 'api_key',
|
|
39
|
+
credential_schema: { version: 1, fields: [] },
|
|
40
|
+
config_schema: { version: 1, fields: [] },
|
|
41
|
+
record_types: [], supports_webhooks: false,
|
|
42
|
+
availability: { available: true, reason: '' },
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const CONNECTED = {
|
|
48
|
+
results: [
|
|
49
|
+
{
|
|
50
|
+
id: 'c1', public_id: 'pub1', name: 'Tandem Granola',
|
|
51
|
+
connector_key: 'granola', connector_name: 'Granola',
|
|
52
|
+
external_account_id: '', account_label: 'tandem',
|
|
53
|
+
capabilities_enabled: ['poll'], config: {}, status: 'connected',
|
|
54
|
+
health: 'ok', auth_mode: 'api_key',
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
permissions: { view: true, manage: true, issue_tokens: true },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** What production held, verbatim, for each of the twenty failed polls. */
|
|
61
|
+
const THE_REAL_REASON =
|
|
62
|
+
'connector_granola poll failed on page 1: [config_error] the response '
|
|
63
|
+
+ 'exceeded the 262144-byte cap and was aborted [granola]';
|
|
64
|
+
|
|
65
|
+
const RUNS: ConnectionRun[] = [
|
|
66
|
+
{
|
|
67
|
+
id: 'r-new', kind: 'destination_run', object_id: 'd1', name: 'Corpus delivery',
|
|
68
|
+
status: 'completed', run_type: 'scheduled',
|
|
69
|
+
occurred_at: '2026-08-27T04:24:08Z', error_message: '',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'r-old', kind: 'source_run', object_id: 's1', name: 'granola-poll',
|
|
73
|
+
status: 'failed', run_type: 'incremental',
|
|
74
|
+
occurred_at: '2026-08-12T20:21:46Z', error_message: THE_REAL_REASON,
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
function stubApi(overrides: Partial<ConnectionsApi> = {}): ConnectionsApi {
|
|
79
|
+
return {
|
|
80
|
+
connectorCatalog: vi.fn().mockResolvedValue(CATALOG),
|
|
81
|
+
connections: vi.fn().mockResolvedValue(CONNECTED),
|
|
82
|
+
connect: vi.fn(),
|
|
83
|
+
checkConnection: vi.fn().mockResolvedValue({ ok: true, account_label: '', detail: '' }),
|
|
84
|
+
disconnect: vi.fn(),
|
|
85
|
+
connectionChoices: vi.fn(),
|
|
86
|
+
wireTarget: vi.fn(),
|
|
87
|
+
confirmTarget: vi.fn(),
|
|
88
|
+
connectionActions: vi.fn(),
|
|
89
|
+
invokeConnectionAction: vi.fn(),
|
|
90
|
+
connectionRuns: vi.fn().mockResolvedValue({ results: RUNS }),
|
|
91
|
+
updateConnection: vi.fn(),
|
|
92
|
+
rotateCredential: vi.fn(),
|
|
93
|
+
...overrides,
|
|
94
|
+
} as unknown as ConnectionsApi;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function openActivity(api: ConnectionsApi = stubApi()) {
|
|
98
|
+
render(<ConnectionsPage api={api} foundrySlug="tandem" />);
|
|
99
|
+
const button = await screen.findByRole('button', { name: /activity/i });
|
|
100
|
+
await userEvent.click(button);
|
|
101
|
+
return api;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe('ConnectionCard — recent activity', () => {
|
|
105
|
+
it('has a control at all, which is the whole of the gap', async () => {
|
|
106
|
+
render(<ConnectionsPage api={stubApi()} foundrySlug="tandem" />);
|
|
107
|
+
|
|
108
|
+
expect(await screen.findByRole('button', { name: /activity/i })).toBeInTheDocument();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('reads the runs endpoint that nothing has ever called', async () => {
|
|
112
|
+
const api = await openActivity();
|
|
113
|
+
|
|
114
|
+
await waitFor(() => expect(api.connectionRuns).toHaveBeenCalledWith('tandem', 'c1'));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('renders the reason a failed run gives, not only its status word', async () => {
|
|
118
|
+
await openActivity();
|
|
119
|
+
|
|
120
|
+
expect(await screen.findByText(/262144-byte cap/)).toBeInTheDocument();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('names which leg the run belongs to, since the card merges three tables', async () => {
|
|
124
|
+
await openActivity();
|
|
125
|
+
|
|
126
|
+
const activity = await screen.findByRole('group', { name: /recent activity/i });
|
|
127
|
+
expect(activity).toHaveTextContent(/granola-poll/);
|
|
128
|
+
expect(activity).toHaveTextContent(/Corpus delivery/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('invents no reason for a run that succeeded', async () => {
|
|
132
|
+
await openActivity(stubApi({
|
|
133
|
+
connectionRuns: vi.fn().mockResolvedValue({ results: [RUNS[0]] }),
|
|
134
|
+
}) as ConnectionsApi);
|
|
135
|
+
|
|
136
|
+
const activity = await screen.findByRole('group', { name: /recent activity/i });
|
|
137
|
+
expect(activity).not.toHaveTextContent(/262144/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('says so when there is no activity, rather than rendering an empty box', async () => {
|
|
141
|
+
await openActivity(stubApi({
|
|
142
|
+
connectionRuns: vi.fn().mockResolvedValue({ results: [] }),
|
|
143
|
+
}) as ConnectionsApi);
|
|
144
|
+
|
|
145
|
+
const activity = await screen.findByRole('group', { name: /recent activity/i });
|
|
146
|
+
expect(activity).toHaveTextContent(/no runs yet/i);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('offers no control when the client is too old to have the verb', async () => {
|
|
150
|
+
// Apps consume `@startsimpli/*` as PUBLISHED packages, so a client pinned
|
|
151
|
+
// to an older `@startsimpli/api` is a normal state of the world. The gate
|
|
152
|
+
// is the verb existing — the same rule Workflows and Settings use.
|
|
153
|
+
const api = stubApi();
|
|
154
|
+
delete (api as Partial<ConnectionsApi>).connectionRuns;
|
|
155
|
+
render(<ConnectionsPage api={api} foundrySlug="tandem" />);
|
|
156
|
+
|
|
157
|
+
await screen.findByText('Tandem Granola');
|
|
158
|
+
expect(screen.queryByRole('button', { name: /activity/i })).not.toBeInTheDocument();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CLIENT ASKS THE SERVER FOR A NARROWER ANSWER, AND CHECKS THAT IT GOT ONE.
|
|
3
|
+
*
|
|
4
|
+
* Two bugs, one root cause (bd startsim-o0o6o.2, bd startsim-o0o6o.3). The
|
|
5
|
+
* corpus adapter was written against a tenant data plane that filtered on
|
|
6
|
+
* `?type=` and nothing else, and several of its comments still assert that as
|
|
7
|
+
* fact. It has not been true since bd startsim-768w.38.12 and bd startsim-te3r:
|
|
8
|
+
* `RelationshipQuery` applies `entity__in` bounded at `MAX_ID_LIST` ids, and
|
|
9
|
+
* `TenantPageNumberPagination` honours `page_size` up to 200.
|
|
10
|
+
*
|
|
11
|
+
* WHY THE ANSWER IS CHECKED AND NOT ASSUMED. The tenant's response to a filter
|
|
12
|
+
* it does not recognise is an UNFILTERED PAGE with the parameter named in
|
|
13
|
+
* `ignored_filters` (bd startsim-768w.38.39) — not a 400 and not an empty list.
|
|
14
|
+
* So "send the filter and trust the rows" is the dangerous shape here: the reply
|
|
15
|
+
* to a narrowing request that was ignored looks exactly like a narrow answer
|
|
16
|
+
* with a lot of rows in it. start-simpli#154 set the precedent — read the
|
|
17
|
+
* server's own `applied_filters` and do not use an unconfirmed result — and the
|
|
18
|
+
* fakes below behave the way the wire behaves, ignoring what they do not support
|
|
19
|
+
* and answering as wide as they were asked.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, expect, it } from 'vitest';
|
|
22
|
+
|
|
23
|
+
import { createCorpusApi } from '../corpus/client';
|
|
24
|
+
import type { CorpusHttp } from '../corpus/client';
|
|
25
|
+
import { startExtractionRun } from '../corpus/runs';
|
|
26
|
+
import {
|
|
27
|
+
DEFAULT_RUN_KEY,
|
|
28
|
+
DEFAULT_TYPE_KEYS,
|
|
29
|
+
MAX_ID_LIST,
|
|
30
|
+
extractionRunToEntity,
|
|
31
|
+
} from '../corpus/entities';
|
|
32
|
+
import type { TenantEntity, TenantRelationship } from '../corpus/entities';
|
|
33
|
+
import {
|
|
34
|
+
createFixtureCorpusApi,
|
|
35
|
+
createFixtureCorpusHttp,
|
|
36
|
+
createFixtureExtractor,
|
|
37
|
+
FIXTURE_ORG,
|
|
38
|
+
FIXTURE_SOURCE_LABELS,
|
|
39
|
+
} from '../corpus/fixtures';
|
|
40
|
+
import type { ExtractionRun, ExtractionRunStore } from '../corpus/types';
|
|
41
|
+
|
|
42
|
+
const SCOPE = { orgId: FIXTURE_ORG };
|
|
43
|
+
|
|
44
|
+
function apiOver(overrides: Parameters<typeof createFixtureCorpusHttp>[0] = {}) {
|
|
45
|
+
const { http, requests } = createFixtureCorpusHttp(overrides);
|
|
46
|
+
const api = createCorpusApi({ http, scope: SCOPE, sourceLabels: FIXTURE_SOURCE_LABELS });
|
|
47
|
+
return { api, requests };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// bd startsim-o0o6o.3 — the sweep record, past the fifth row
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
function run(id: string): ExtractionRun {
|
|
55
|
+
return {
|
|
56
|
+
runId: id,
|
|
57
|
+
scope: SCOPE,
|
|
58
|
+
status: 'succeeded',
|
|
59
|
+
cursor: 4,
|
|
60
|
+
totalDocuments: 4,
|
|
61
|
+
totals: {
|
|
62
|
+
documentsProcessed: 4, fromRules: 2, fromModel: 1, ungrounded: 0, unconfirmed: 0,
|
|
63
|
+
created: 3, merged: 0, resolved: 0, droppedOutOfScope: 0, modelCalls: 4, modelFailures: 0,
|
|
64
|
+
},
|
|
65
|
+
processedIds: [],
|
|
66
|
+
modelIncompleteIds: [],
|
|
67
|
+
needsReview: [],
|
|
68
|
+
provider: { provider: 'fixture', model: 'fixture-rules', live: false },
|
|
69
|
+
startedAt: '2026-08-01T10:00:00Z',
|
|
70
|
+
updatedAt: '2026-08-01T10:06:00Z',
|
|
71
|
+
finishedAt: '2026-08-01T10:06:00Z',
|
|
72
|
+
leaseOwner: null,
|
|
73
|
+
leaseUntil: null,
|
|
74
|
+
error: null,
|
|
75
|
+
lastModelFailure: null,
|
|
76
|
+
history: [],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The singleton row, exactly as `writeRun` lands it. */
|
|
81
|
+
function singleton(): TenantEntity {
|
|
82
|
+
return {
|
|
83
|
+
id: 'run-singleton',
|
|
84
|
+
...extractionRunToEntity(run('sweep-1'), DEFAULT_TYPE_KEYS.extractionRun, DEFAULT_RUN_KEY),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Rows that are NOT the singleton, and which is which is not a hypothetical.
|
|
90
|
+
*
|
|
91
|
+
* A second workspace key, a renamed `runKey`, a partial migration or a
|
|
92
|
+
* hand-written row all put `extraction_run` rows next to it, and the tenant
|
|
93
|
+
* orders entities `-created_at` — so the singleton, which is the OLDEST because
|
|
94
|
+
* it was written first and is rewritten in place, sorts LAST. Newest first is
|
|
95
|
+
* the order these arrive in.
|
|
96
|
+
*/
|
|
97
|
+
function noise(count: number): TenantEntity[] {
|
|
98
|
+
return Array.from({ length: count }, (_, index) => ({
|
|
99
|
+
id: `run-noise-${index}`,
|
|
100
|
+
entity_type: DEFAULT_TYPE_KEYS.extractionRun,
|
|
101
|
+
external_id: `corpus-extraction-sweep-other-${index}`,
|
|
102
|
+
name: `Extraction sweep other-${index}`,
|
|
103
|
+
data: { run: JSON.stringify(run(`other-${index}`)), _origin: 'tenant-extraction' },
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe('readRun tells "nothing has ever run" apart from "I could not tell"', () => {
|
|
108
|
+
it('finds the singleton when it is NOT among the newest five rows', async () => {
|
|
109
|
+
// Six rows, singleton last: the exact corpus at which `page_size: 5` starts
|
|
110
|
+
// answering `null` — which every caller reads as "no sweep has ever run" and
|
|
111
|
+
// `startExtractionRun` acts on by starting a duplicate sweep.
|
|
112
|
+
const { api } = apiOver({ entities: [...noise(5), singleton()], relationships: [] });
|
|
113
|
+
|
|
114
|
+
const read = await api.readRun();
|
|
115
|
+
|
|
116
|
+
expect(read?.runId).toBe('sweep-1');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('THROWS rather than reporting "never run" when the row could be past the cap', async () => {
|
|
120
|
+
// Past the server's own `max_page_size`, one read genuinely cannot see every
|
|
121
|
+
// row — and the honest answer to "has a sweep run here" is then neither the
|
|
122
|
+
// run nor `null`. `null` is a FACT in this contract; a truncated page is not
|
|
123
|
+
// evidence for it.
|
|
124
|
+
const { api } = apiOver({
|
|
125
|
+
entities: [...noise(MAX_ID_LIST + 20), singleton()],
|
|
126
|
+
relationships: [],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await expect(api.readRun()).rejects.toThrow(/could not/i);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('still answers null for a workspace where nothing HAS run', async () => {
|
|
133
|
+
// The fact this whole contract exists to preserve: absence is reportable.
|
|
134
|
+
const { api } = apiOver({ entities: [], relationships: [] });
|
|
135
|
+
|
|
136
|
+
expect(await api.readRun()).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('matches the singleton key in EITHER spelling the wire delivers it in', async () => {
|
|
140
|
+
// `@startsimpli/api`'s fetch wrapper transforms every response key
|
|
141
|
+
// snake_case -> camelCase, so a browser-side read of this row carries
|
|
142
|
+
// `externalId` and never `external_id`. This lookup read one spelling, so in
|
|
143
|
+
// the browser it matched nothing at ANY page size and reported "never run"
|
|
144
|
+
// over a workspace with a sweep in it — the same seam, and the same lie, as
|
|
145
|
+
// reading one spelling of `applied_filters`.
|
|
146
|
+
const { external_id: key, ...rest } = singleton();
|
|
147
|
+
const { api } = apiOver({
|
|
148
|
+
entities: [{ ...rest, externalId: key }],
|
|
149
|
+
relationships: [],
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect((await api.readRun())?.runId).toBe('sweep-1');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('asks for the widest page the server will serve, not five rows', async () => {
|
|
156
|
+
const { api, requests } = apiOver({ entities: [singleton()], relationships: [] });
|
|
157
|
+
await api.readRun();
|
|
158
|
+
|
|
159
|
+
const read = requests.find(
|
|
160
|
+
(request) => request.params.type === DEFAULT_TYPE_KEYS.extractionRun,
|
|
161
|
+
);
|
|
162
|
+
expect(read?.params.page_size).toBe(MAX_ID_LIST);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// bd startsim-o0o6o.2 — the edges of ONE PAGE, not the whole edge table
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
const PARTY: TenantEntity = {
|
|
171
|
+
id: 'party_dana', entity_type: 'party', external_id: 'granola:dana@example.com',
|
|
172
|
+
name: 'Dana Reed',
|
|
173
|
+
data: { display_name: 'Dana Reed', email: 'dana@example.com', source: 'granola' },
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
function document(id: string): TenantEntity {
|
|
177
|
+
return {
|
|
178
|
+
id, entity_type: 'source_document', external_id: `granola:${id}`, name: `Record ${id}`,
|
|
179
|
+
data: { source: 'granola', title: `Record ${id}`, occurred_at: '2026-08-01T09:00:00Z' },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
describe('the participant join reads the edges of a PAGE, not the edge table', () => {
|
|
184
|
+
it('names the page’s ids in entity__in instead of asking for everything', async () => {
|
|
185
|
+
const { api, requests } = apiOver();
|
|
186
|
+
await api.listDocuments({ page: 1, pageSize: 3 });
|
|
187
|
+
|
|
188
|
+
const edgeReads = requests.filter((request) => request.path.includes('relationships'));
|
|
189
|
+
expect(edgeReads.length).toBeGreaterThan(0);
|
|
190
|
+
// THE ASSERTION THIS FILE EXISTS FOR. `readAll(relationships, {})` sent no
|
|
191
|
+
// parameters at all and walked up to twenty pages of every edge in the
|
|
192
|
+
// workspace, on a comment claiming the endpoint had no filters.
|
|
193
|
+
for (const read of edgeReads) {
|
|
194
|
+
const named = String(read.params.entity__in ?? '').split(',').filter(Boolean);
|
|
195
|
+
expect(named.length).toBeGreaterThan(0);
|
|
196
|
+
expect(named.length).toBeLessThanOrEqual(MAX_ID_LIST);
|
|
197
|
+
}
|
|
198
|
+
expect(String(edgeReads[0].params.entity__in).split(',')).toHaveLength(3);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('still resolves the people on the page it narrowed to', async () => {
|
|
202
|
+
const { api } = apiOver({
|
|
203
|
+
entities: [document('doc-1'), PARTY],
|
|
204
|
+
relationships: [{ id: 'e1', rel_type: 'authored_by', source: 'doc-1', target: 'party_dana' }],
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const page = await api.listDocuments({ page: 1 });
|
|
208
|
+
|
|
209
|
+
expect(page.results[0].participants.map((party) => party.name)).toEqual(['Dana Reed']);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('chunks a page wider than the server’s id bound rather than being refused', async () => {
|
|
213
|
+
// A fork whose `max_page_size` is larger than `MAX_ID_LIST` hands back more
|
|
214
|
+
// ids than one `entity__in` may carry, and `_uuid_list` answers a longer
|
|
215
|
+
// list with a 400 — so the ids are chunked. Truncating them instead would
|
|
216
|
+
// drop the people off the tail of the page silently, which is the failure
|
|
217
|
+
// this whole change is undoing.
|
|
218
|
+
const documents = Array.from({ length: MAX_ID_LIST + 40 }, (_, index) =>
|
|
219
|
+
document(`doc-${index}`),
|
|
220
|
+
);
|
|
221
|
+
const last = documents[documents.length - 1];
|
|
222
|
+
const { api, requests } = apiOver({
|
|
223
|
+
pageSize: documents.length,
|
|
224
|
+
entities: [...documents, PARTY],
|
|
225
|
+
relationships: [
|
|
226
|
+
{ id: 'e-last', rel_type: 'involves', source: String(last.id), target: 'party_dana' },
|
|
227
|
+
],
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const page = await api.listDocuments({ page: 1 });
|
|
231
|
+
|
|
232
|
+
const edgeReads = requests.filter((request) => request.path.includes('relationships'));
|
|
233
|
+
expect(edgeReads.length).toBeGreaterThan(1);
|
|
234
|
+
for (const read of edgeReads) {
|
|
235
|
+
expect(String(read.params.entity__in).split(',').length).toBeLessThanOrEqual(MAX_ID_LIST);
|
|
236
|
+
}
|
|
237
|
+
const tail = page.results.find((record) => record.id === String(last.id));
|
|
238
|
+
expect(tail?.participants.map((party) => party.name)).toEqual(['Dana Reed']);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* A fork that has NOT shipped `RelationshipQuery`.
|
|
244
|
+
*
|
|
245
|
+
* It does what the tenant documents itself as doing with a parameter it does not
|
|
246
|
+
* know: names it in `ignored_filters` and answers with the page exactly as wide
|
|
247
|
+
* as it was. A fake that returned `[]` here would let the client pass this suite
|
|
248
|
+
* while reading the whole edge table in production, which is the trap this test
|
|
249
|
+
* is written to spring.
|
|
250
|
+
*/
|
|
251
|
+
function unfilteredEdgeHttp(edges: TenantRelationship[], rows: TenantEntity[]) {
|
|
252
|
+
const requests: Array<{ path: string; params: Record<string, unknown> }> = [];
|
|
253
|
+
const http: CorpusHttp = {
|
|
254
|
+
async get<T>(path: string, init?: { params?: Record<string, unknown> }): Promise<T> {
|
|
255
|
+
const params = init?.params ?? {};
|
|
256
|
+
requests.push({ path, params });
|
|
257
|
+
if (path.includes('relationships')) {
|
|
258
|
+
return {
|
|
259
|
+
count: edges.length, next: null, previous: null, results: edges,
|
|
260
|
+
applied_filters: [],
|
|
261
|
+
ignored_filters: Object.keys(params).filter(
|
|
262
|
+
(key) => key !== 'page' && key !== 'page_size',
|
|
263
|
+
),
|
|
264
|
+
} as T;
|
|
265
|
+
}
|
|
266
|
+
if (path.includes('entities')) {
|
|
267
|
+
const type = params.type ? String(params.type) : '';
|
|
268
|
+
const matching = type ? rows.filter((row) => row.entity_type === type) : rows;
|
|
269
|
+
return {
|
|
270
|
+
count: matching.length, next: null, previous: null, results: matching,
|
|
271
|
+
applied_filters: [],
|
|
272
|
+
ignored_filters: [],
|
|
273
|
+
} as T;
|
|
274
|
+
}
|
|
275
|
+
throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
return { http, requests };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
describe('an unconfirmed narrowing is not used', () => {
|
|
282
|
+
it('renders no participants rather than joining an unnarrowed page of edges', async () => {
|
|
283
|
+
// start-simpli#154's precedent, on the read that matters most: the server
|
|
284
|
+
// reported it did NOT apply `entity__in`, so the rows it sent are an
|
|
285
|
+
// arbitrary page of the whole edge table and joining them would put the
|
|
286
|
+
// wrong people — or a partial list of the right ones — on the record. The
|
|
287
|
+
// Library degrades to what it showed before the join existed.
|
|
288
|
+
const { http, requests } = unfilteredEdgeHttp(
|
|
289
|
+
[{ id: 'e1', rel_type: 'authored_by', source: 'doc-1', target: 'party_dana' }],
|
|
290
|
+
[document('doc-1'), PARTY],
|
|
291
|
+
);
|
|
292
|
+
const api = createCorpusApi({ http, scope: SCOPE, sourceLabels: FIXTURE_SOURCE_LABELS });
|
|
293
|
+
|
|
294
|
+
const page = await api.listDocuments({ page: 1 });
|
|
295
|
+
|
|
296
|
+
expect(page.results).toHaveLength(1);
|
|
297
|
+
expect(page.results[0].participants).toEqual([]);
|
|
298
|
+
// And it does not go on to read the party roster for a join it will not do.
|
|
299
|
+
expect(requests.filter((request) => request.params.type === 'party')).toHaveLength(0);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe('a store that could not answer never becomes "no sweep has ever run"', () => {
|
|
304
|
+
it('refuses to start a second sweep over a cursor it was unable to read', async () => {
|
|
305
|
+
// THE REASON THE THIRD ANSWER HAD TO REACH THIS CALLER. `startExtractionRun`
|
|
306
|
+
// swallowed every `readRun` failure into `null` and `null` means "start a
|
|
307
|
+
// new sweep" one branch later, so an undetermined read put a SECOND driver
|
|
308
|
+
// on a live cursor — and two model calls over one document produce two
|
|
309
|
+
// differently-worded items dedupe cannot see are the same, which is how a
|
|
310
|
+
// live re-run once turned 82 commitments into 98.
|
|
311
|
+
let writes = 0;
|
|
312
|
+
const runs: ExtractionRunStore = {
|
|
313
|
+
async readRun() {
|
|
314
|
+
throw Object.assign(
|
|
315
|
+
new Error('Could not determine whether a sweep has run in this workspace.'),
|
|
316
|
+
{ status: 409 },
|
|
317
|
+
);
|
|
318
|
+
},
|
|
319
|
+
async writeRun(run) {
|
|
320
|
+
writes += 1;
|
|
321
|
+
return run;
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
await expect(
|
|
326
|
+
startExtractionRun(
|
|
327
|
+
{ scope: SCOPE },
|
|
328
|
+
{
|
|
329
|
+
corpus: createFixtureCorpusApi({ scope: SCOPE }),
|
|
330
|
+
extractor: createFixtureExtractor(),
|
|
331
|
+
runs,
|
|
332
|
+
},
|
|
333
|
+
),
|
|
334
|
+
).rejects.toThrow(/could not determine/i);
|
|
335
|
+
expect(writes).toBe(0);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
@@ -191,8 +191,10 @@ describe('both storage shapes, because both are real', () => {
|
|
|
191
191
|
});
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
-
describe('the graph read is
|
|
195
|
-
it('costs ONE
|
|
194
|
+
describe('the graph read is narrowed, cached and fails soft', () => {
|
|
195
|
+
it('costs ONE edge read across a list, a detail and a second page', async () => {
|
|
196
|
+
// The records are remembered per row, so re-listing them and then opening
|
|
197
|
+
// one of them asks the server nothing further (bd startsim-o0o6o.2).
|
|
196
198
|
const { api, requests } = apiOver();
|
|
197
199
|
await api.listDocuments({ page: 1 });
|
|
198
200
|
await api.listDocuments({ page: 1 });
|
|
@@ -220,17 +222,26 @@ describe('the graph read is bounded, cached and fails soft', () => {
|
|
|
220
222
|
expect(page.results.every((document) => document.participants.length === 0)).toBe(true);
|
|
221
223
|
});
|
|
222
224
|
|
|
223
|
-
it('
|
|
224
|
-
//
|
|
225
|
-
//
|
|
225
|
+
it('narrows the edge read to the ids of the page it is rendering', async () => {
|
|
226
|
+
// THIS TEST USED TO ASSERT THE OPPOSITE, and its title said why: "walks
|
|
227
|
+
// every page of the graph, since the tenant ignores page_size". Neither half
|
|
228
|
+
// survived contact with the deployed server. `page_size` is honoured
|
|
229
|
+
// (`TenantPageNumberPagination`, bd startsim-768w.38.12) and the edge table
|
|
230
|
+
// is filterable (`RelationshipQuery`, bd startsim-te3r) — so a page deep in
|
|
231
|
+
// the corpus no longer reaches its people by reading every edge in the
|
|
232
|
+
// workspace and joining in the browser. It ASKS for the edges of the two
|
|
233
|
+
// rows it is rendering, which is the read that scales with the page instead
|
|
234
|
+
// of with the corpus (bd startsim-o0o6o.2).
|
|
226
235
|
const { api, requests } = apiOver({ pageSize: 2 });
|
|
227
|
-
|
|
228
|
-
// it — so the Dropbox row is on page THREE however wide the caller asked.
|
|
229
|
-
const page = await api.listDocuments({ page: 3, pageSize: 100 });
|
|
236
|
+
const page = await api.listDocuments({ page: 3 });
|
|
230
237
|
const dropbox = page.results.find((document) => document.id === 'dbx_9812f0');
|
|
231
238
|
|
|
232
239
|
expect(dropbox?.participants.map((party) => party.name)).toEqual(['Acme Robotics', 'Dana Reed']);
|
|
233
|
-
|
|
240
|
+
const edgeReads = requests.filter((request) => request.path.includes('relationships'));
|
|
241
|
+
expect(edgeReads).toHaveLength(1);
|
|
242
|
+
expect(String(edgeReads[0].params.entity__in).split(',')).toEqual(
|
|
243
|
+
page.results.map((document) => document.id),
|
|
244
|
+
);
|
|
234
245
|
});
|
|
235
246
|
});
|
|
236
247
|
|