@startsimpli/integrations 0.2.31 → 0.2.33
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__/corpus-narrowed-reads.test.ts +337 -0
- package/src/__tests__/corpus-source-strip.test.tsx +225 -0
- package/src/__tests__/corpus-storage.test.ts +20 -9
- package/src/components/pages/LibraryPage.tsx +26 -2
- package/src/corpus/client.ts +294 -65
- package/src/corpus/entities.ts +41 -0
- package/src/corpus/fixtures.ts +108 -10
- package/src/corpus/runs.ts +10 -1
- package/src/corpus/types.ts +16 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.33",
|
|
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,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
|
+
});
|
|
@@ -241,3 +241,228 @@ describe('the Library strip, rendered', () => {
|
|
|
241
241
|
expect(screen.queryByText(/Dropbox/)).not.toBeInTheDocument();
|
|
242
242
|
});
|
|
243
243
|
});
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* THE COUNT ON THE CARD IS A CORPUS FACT, AND IT WAS NEVER READ
|
|
247
|
+
* (bd startsim-jevtu RED, first filed as startsim-o0o6o.1).
|
|
248
|
+
*
|
|
249
|
+
* REPORTED ON THE LIVE TANDEM TENANT, 2026-08-26: Dropbox, Fathom, Granola and
|
|
250
|
+
* n8n each read "0 records" while the list directly beneath the strip read
|
|
251
|
+
* "208 records · page 1 of 11". `listSources()` computed the number as
|
|
252
|
+
* `connection.document_count ?? connection.documentCount ?? 0` and NO
|
|
253
|
+
* connections endpoint sends either spelling — `SubtreeConnectionsView`
|
|
254
|
+
* serialises id, connector_key, connector_name, name, status, account_label,
|
|
255
|
+
* the three timestamps, consecutive_failures and check_error, and no count. So
|
|
256
|
+
* every card has read `0` for every foundry since the strip shipped.
|
|
257
|
+
*
|
|
258
|
+
* WHY THE NUMBER COMES FROM THE TENANT CORPUS AND NOT FROM CENTRAL.
|
|
259
|
+
*
|
|
260
|
+
* 1. Central does not have the rows. The corpus lives in the fork; central
|
|
261
|
+
* delivers INTO it (`corpus_delivery.py`) and keeps no copy. A count
|
|
262
|
+
* annotated onto the connection would be a count of records central
|
|
263
|
+
* believes it shipped — a different number from the one in the list under
|
|
264
|
+
* the strip the moment an upsert collapses two records, a retention rule
|
|
265
|
+
* trims, or a delivery half-lands. A strip that disagrees with the list
|
|
266
|
+
* beneath it is the exact fault class this bead is about; a delivery
|
|
267
|
+
* counter would re-open it in a new spelling.
|
|
268
|
+
*
|
|
269
|
+
* 2. Every other fact on the card already comes from central's connection
|
|
270
|
+
* row — status, the three timestamps, the failure streak. The corpus count
|
|
271
|
+
* is the ONLY corpus-side fact on a strip that sits on top of the corpus,
|
|
272
|
+
* and that is what makes it worth its round trip: "central says it synced
|
|
273
|
+
* an hour ago, the corpus holds nothing from it" is the delivery-hop
|
|
274
|
+
* failure, and no connection field can say it.
|
|
275
|
+
*
|
|
276
|
+
* WHY THIS IS NOT A SCAN. The tenant's own `/api/v1/capabilities/` declares
|
|
277
|
+
* `"counts": "page-count"` and says in as many words that a focus card's
|
|
278
|
+
* "12 of 40 done" is "two `page_size=1` reads off the paginator's own `count` —
|
|
279
|
+
* said here so a client does not go looking for a group_by endpoint that
|
|
280
|
+
* deliberately does not exist". One narrowed request per source, asking for one
|
|
281
|
+
* row, reading the envelope's `count`: one SQL COUNT with the filter pushed
|
|
282
|
+
* down, which is the sanctioned idiom on this platform and not eleven pages.
|
|
283
|
+
*
|
|
284
|
+
* WHY IT IS UNBLOCKED. bd startsim-768w.38.12 shipped `?source=` and verified it
|
|
285
|
+
* on this very tenant ("source=granola 16 / stripe 0"). The vocabulary lines up
|
|
286
|
+
* by construction, not by luck: central serialises `connector_key` as
|
|
287
|
+
* `connection.connector.key`, `corpus_delivery` stamps `source_key =
|
|
288
|
+
* connector_cls.key` onto every projected record, and the catalog reconciler
|
|
289
|
+
* creates each `DestinationProvider` row AT the connector class's own key. The
|
|
290
|
+
* canonical mapping says it outright — "`source` is the connector key, so
|
|
291
|
+
* 'everything from Granola' is one filter".
|
|
292
|
+
*
|
|
293
|
+
* AND WHY `null` HAS TO EXIST. `0` today means "we never asked". After this it
|
|
294
|
+
* must mean "we asked and the answer is none" — otherwise the fix replaces a
|
|
295
|
+
* lie with a lie that happens to be right some of the time. A build that cannot
|
|
296
|
+
* narrow by source returns the WHOLE corpus for every card, which would print
|
|
297
|
+
* "208 records" four times over; the server's own `applied_filters` is what
|
|
298
|
+
* tells the two apart, and a count that cannot be trusted is not printed.
|
|
299
|
+
*/
|
|
300
|
+
|
|
301
|
+
/** Per-source totals as a tenant that really does honour `?source=` reports them. */
|
|
302
|
+
function corpusOver(
|
|
303
|
+
rows: Array<Record<string, unknown>>,
|
|
304
|
+
counts: Record<string, number>,
|
|
305
|
+
options: { reports?: 'snake' | 'camel' | 'none' } = {},
|
|
306
|
+
): Recorder {
|
|
307
|
+
const reports = options.reports ?? 'snake';
|
|
308
|
+
const whole = Object.values(counts).reduce((sum, n) => sum + n, 0);
|
|
309
|
+
const calls: Array<{ path: string; params?: Record<string, unknown> }> = [];
|
|
310
|
+
return {
|
|
311
|
+
calls,
|
|
312
|
+
http: {
|
|
313
|
+
async get<T>(path: string, init?: { params?: Record<string, unknown> }): Promise<T> {
|
|
314
|
+
const params = init?.params ?? {};
|
|
315
|
+
calls.push({ path, params });
|
|
316
|
+
if (path.includes('connections')) {
|
|
317
|
+
return { count: rows.length, next: null, previous: null, results: rows } as T;
|
|
318
|
+
}
|
|
319
|
+
const source = params.source === undefined ? '' : String(params.source);
|
|
320
|
+
// A build with no `?source=` narrows NOTHING and hands back the whole
|
|
321
|
+
// corpus — the failure mode that would print 208 on every card.
|
|
322
|
+
const narrowed = reports !== 'none' && Boolean(source);
|
|
323
|
+
const count = narrowed ? (counts[source] ?? 0) : whole;
|
|
324
|
+
const applied = narrowed ? ['type', 'source'] : ['type'];
|
|
325
|
+
return {
|
|
326
|
+
count,
|
|
327
|
+
next: null,
|
|
328
|
+
previous: null,
|
|
329
|
+
// Faithful to the wire: a `page_size=1` read over a non-empty match
|
|
330
|
+
// returns a row. A count read must not need it.
|
|
331
|
+
results: count > 0 ? [{ id: 'row', entity_type: 'source_document', data: {} }] : [],
|
|
332
|
+
...(reports === 'snake' ? { applied_filters: applied } : {}),
|
|
333
|
+
...(reports === 'camel' ? { appliedFilters: applied } : {}),
|
|
334
|
+
} as T;
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** A second shared connection, one that has delivered nothing — as tandem has. */
|
|
341
|
+
const SHARED_N8N = {
|
|
342
|
+
id: 'shared-n8n',
|
|
343
|
+
connector_key: 'n8n',
|
|
344
|
+
connector_name: 'n8n',
|
|
345
|
+
status: 'connected',
|
|
346
|
+
check_error: '',
|
|
347
|
+
last_synced_at: AN_HOUR_AGO,
|
|
348
|
+
last_attempt_at: AN_HOUR_AGO,
|
|
349
|
+
last_checked_at: AN_HOUR_AGO,
|
|
350
|
+
consecutive_failures: 0,
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const TANDEM_COUNTS = { granola: 208, n8n: 0 };
|
|
354
|
+
|
|
355
|
+
function countedSources(options: { reports?: 'snake' | 'camel' | 'none' } = {}) {
|
|
356
|
+
const { http } = corpusOver([SHARED_GRANOLA, SHARED_N8N], TANDEM_COUNTS, options);
|
|
357
|
+
return createCorpusApi({ http, scope: SCOPE }).listSources();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
describe('listSources — the record count is counted, not read off the connection', () => {
|
|
361
|
+
it('reports what each source actually put in the corpus', async () => {
|
|
362
|
+
const sources = await countedSources();
|
|
363
|
+
const byKey = Object.fromEntries(sources.map((source) => [source.source, source.documentCount]));
|
|
364
|
+
|
|
365
|
+
// The whole bug in one assertion: Granola holds 208 records and the card
|
|
366
|
+
// said 0, because it was reading a field central has never sent.
|
|
367
|
+
expect(byKey).toEqual({ granola: 208, n8n: 0 });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('counts DOCUMENTS, so the strip agrees with the list beneath it', async () => {
|
|
371
|
+
// A count over every entity type would sweep in parties, money events and
|
|
372
|
+
// commitments and overshoot the list it sits above. Same type key, same
|
|
373
|
+
// read gate, same server — the two agree by construction or not at all.
|
|
374
|
+
const recorder = corpusOver([SHARED_GRANOLA, SHARED_N8N], TANDEM_COUNTS);
|
|
375
|
+
await createCorpusApi({ http: recorder.http, scope: SCOPE }).listSources();
|
|
376
|
+
|
|
377
|
+
const reads = recorder.calls.filter((call) => call.path.includes('entities'));
|
|
378
|
+
expect(reads.length).toBeGreaterThan(0);
|
|
379
|
+
for (const call of reads) expect(call.params?.type).toBe('source_document');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('asks for ONE row per source and never pages the corpus', async () => {
|
|
383
|
+
// "Eleven pages per card" is the same wrong answer with worse latency. The
|
|
384
|
+
// count comes off the paginator's own envelope, which costs one row.
|
|
385
|
+
const recorder = corpusOver([SHARED_GRANOLA, SHARED_N8N], TANDEM_COUNTS);
|
|
386
|
+
await createCorpusApi({ http: recorder.http, scope: SCOPE }).listSources();
|
|
387
|
+
|
|
388
|
+
const reads = recorder.calls.filter((call) => call.path.includes('entities'));
|
|
389
|
+
expect(reads).toHaveLength(2);
|
|
390
|
+
expect(reads.map((call) => call.params?.source).sort()).toEqual(['granola', 'n8n']);
|
|
391
|
+
for (const call of reads) expect(call.params?.page_size).toBe(1);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('counts a vendor once when two connections name it', async () => {
|
|
395
|
+
// A card is per CONNECTION, a count is per SOURCE. Central's page unions the
|
|
396
|
+
// foundry's shared connections with the member's own, so one vendor can
|
|
397
|
+
// arrive twice — and whose account it is does not narrow the corpus. Two
|
|
398
|
+
// reads for one answer, and both cards must report that answer rather than
|
|
399
|
+
// half of it each.
|
|
400
|
+
const MY_GRANOLA = { ...SHARED_GRANOLA, id: 'my-granola', owner: 'qosha@debugg.ai' };
|
|
401
|
+
const recorder = corpusOver([SHARED_GRANOLA, MY_GRANOLA, SHARED_N8N], TANDEM_COUNTS);
|
|
402
|
+
const sources = await createCorpusApi({ http: recorder.http, scope: SCOPE }).listSources();
|
|
403
|
+
|
|
404
|
+
const reads = recorder.calls.filter((call) => call.path.includes('entities'));
|
|
405
|
+
expect(reads.filter((call) => call.params?.source === 'granola')).toHaveLength(1);
|
|
406
|
+
expect(
|
|
407
|
+
sources.filter((source) => source.source === 'granola').map((s) => s.documentCount),
|
|
408
|
+
).toEqual([208, 208]);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('says 0 because the corpus answered 0, not because nobody asked', async () => {
|
|
412
|
+
const n8n = (await countedSources()).find((source) => source.source === 'n8n');
|
|
413
|
+
|
|
414
|
+
// n8n declares no corpus mapping at all (`corpus_delivery` returns early on
|
|
415
|
+
// it), so zero is the true answer — and it has to be distinguishable from
|
|
416
|
+
// the count nobody went and got.
|
|
417
|
+
expect(n8n?.documentCount).toBe(0);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('reads the server’s report in either spelling', async () => {
|
|
421
|
+
// `@startsimpli/api` camelCases response keys in the browser and a plain
|
|
422
|
+
// server-side fetch does not. Reading one spelling made the deployed
|
|
423
|
+
// Tracker report the server was not filtering while it verifiably was; the
|
|
424
|
+
// same seam decides here whether a real count is thrown away.
|
|
425
|
+
const sources = await countedSources({ reports: 'camel' });
|
|
426
|
+
|
|
427
|
+
expect(sources.find((source) => source.source === 'granola')?.documentCount).toBe(208);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('refuses to print the whole corpus when the server could not narrow by source', async () => {
|
|
431
|
+
// The regression this fix could otherwise introduce: an older fork ignores
|
|
432
|
+
// `?source=`, hands back the total, and every card reads "208 records". The
|
|
433
|
+
// server says what it applied; a count it did not narrow is not a count.
|
|
434
|
+
const sources = await countedSources({ reports: 'none' });
|
|
435
|
+
|
|
436
|
+
expect(sources.map((source) => source.documentCount)).toEqual([null, null]);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
describe('the Library strip, counting', () => {
|
|
441
|
+
const strip = async () =>
|
|
442
|
+
within(await screen.findByRole('region', { name: 'Source freshness' }));
|
|
443
|
+
|
|
444
|
+
it('prints the count the corpus actually holds', async () => {
|
|
445
|
+
const { http } = corpusOver([SHARED_GRANOLA, SHARED_N8N], TANDEM_COUNTS);
|
|
446
|
+
render(<LibraryPage api={createCorpusApi({ http, scope: SCOPE })} scope={SCOPE} />);
|
|
447
|
+
|
|
448
|
+
expect((await strip()).getByText(/208 records/)).toBeInTheDocument();
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('says a source has delivered nothing, in words', async () => {
|
|
452
|
+
const { http } = corpusOver([SHARED_GRANOLA, SHARED_N8N], TANDEM_COUNTS);
|
|
453
|
+
render(<LibraryPage api={createCorpusApi({ http, scope: SCOPE })} scope={SCOPE} />);
|
|
454
|
+
|
|
455
|
+
expect((await strip()).getByText(/No records yet/)).toBeInTheDocument();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('prints no number at all when the count is unknown', async () => {
|
|
459
|
+
const { http } = corpusOver([SHARED_GRANOLA, SHARED_N8N], TANDEM_COUNTS, { reports: 'none' });
|
|
460
|
+
render(<LibraryPage api={createCorpusApi({ http, scope: SCOPE })} scope={SCOPE} />);
|
|
461
|
+
|
|
462
|
+
const shown = await strip();
|
|
463
|
+
// The freshness half of the card is unaffected — a count it could not get
|
|
464
|
+
// costs the reader the count, not the sentence.
|
|
465
|
+
expect(shown.getAllByText(/synced/).length).toBeGreaterThan(0);
|
|
466
|
+
expect(shown.queryByText(/record/)).not.toBeInTheDocument();
|
|
467
|
+
});
|
|
468
|
+
});
|
|
@@ -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
|
|
|
@@ -519,6 +519,31 @@ const FRESHNESS_WORDS: Record<SourceFreshness['status'], string> = {
|
|
|
519
519
|
never: 'Nothing yet',
|
|
520
520
|
};
|
|
521
521
|
|
|
522
|
+
/**
|
|
523
|
+
* The count clause of a source card, or nothing at all (bd startsim-jevtu).
|
|
524
|
+
*
|
|
525
|
+
* THREE STATES, NOT TWO. `null` is "this workspace's API could not narrow the
|
|
526
|
+
* corpus by source, so the only number available describes every source at
|
|
527
|
+
* once" — printing it would put the same total on every card, which is the
|
|
528
|
+
* failure this replaced wearing a bigger number. Saying nothing is the honest
|
|
529
|
+
* option, and it costs the reader the count rather than the card.
|
|
530
|
+
*
|
|
531
|
+
* `0` is spelled out in words rather than left as a bare digit because "0
|
|
532
|
+
* records" is exactly what the broken version printed for years; a source that
|
|
533
|
+
* has genuinely delivered nothing deserves a sentence that could not be
|
|
534
|
+
* produced by not asking.
|
|
535
|
+
*
|
|
536
|
+
* NOTE THE SCOPE. This is the source's WHOLE contribution, unfiltered — the
|
|
537
|
+
* strip describes sources, while the list beneath it answers the current query.
|
|
538
|
+
* A card reading "208 records" above a list reading "3 records" under an active
|
|
539
|
+
* search is the two questions being different, not a disagreement.
|
|
540
|
+
*/
|
|
541
|
+
function countClause(count: SourceFreshness['documentCount']): string {
|
|
542
|
+
if (count === null || count === undefined) return '';
|
|
543
|
+
if (count === 0) return 'No records yet · ';
|
|
544
|
+
return `${count} record${count === 1 ? '' : 's'} · `;
|
|
545
|
+
}
|
|
546
|
+
|
|
522
547
|
function FreshnessStrip({
|
|
523
548
|
sources, classNames: cn, now,
|
|
524
549
|
}: { sources: SourceFreshness[]; classNames: Record<LibrarySlot, string>; now: number }) {
|
|
@@ -537,8 +562,7 @@ function FreshnessStrip({
|
|
|
537
562
|
</span>
|
|
538
563
|
</div>
|
|
539
564
|
<p className={cn.muted}>
|
|
540
|
-
{source.documentCount}
|
|
541
|
-
{relative(source.lastSyncedAt, now)}
|
|
565
|
+
{countClause(source.documentCount)}synced {relative(source.lastSyncedAt, now)}
|
|
542
566
|
</p>
|
|
543
567
|
{/* A source that is failing says so HERE, next to the corpus it is
|
|
544
568
|
supposed to be filling — not on a settings page nobody opens. */}
|