@startsimpli/integrations 0.1.5 → 0.2.1

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.
@@ -16,23 +16,51 @@
16
16
  * `apps/api/views.py`). So `supportedFilters` defaults to `[]` and the composer
17
17
  * says so on screen rather than pretending. Pass the real list once the tenant
18
18
  * API gains them; the request already sends every parameter.
19
+ *
20
+ * PEOPLE ARE A SECOND READ, NOT A SECOND COPY (bd startsim-768w.38.22). The
21
+ * corpus delivery writes participants as typed `authored_by` / `involves` edges
22
+ * and leaves only a `participant_count` on the document, so this adapter reads
23
+ * `/api/v1/relationships/` and joins. The alternative — denormalizing names back
24
+ * onto `data.participants` at delivery time — is one fewer round trip and a
25
+ * second copy of the graph that will drift; the edge is the declared contract,
26
+ * so the edge is what is read. The join is cached, bounded and fails soft.
19
27
  */
20
- import { toMoneyEvent, toParty, toSourceDocument, DEFAULT_TYPE_KEYS } from './entities';
21
- import type { CorpusTypeKeys, TenantEntity } from './entities';
28
+ import {
29
+ DEFAULT_PARTICIPANT_EDGES,
30
+ DEFAULT_TYPE_KEYS,
31
+ commitmentToEntity,
32
+ indexParticipants,
33
+ toCommitment,
34
+ toMoneyEvent,
35
+ toParty,
36
+ toSourceDocument,
37
+ } from './entities';
38
+ import type { CorpusTypeKeys, TenantEntity, TenantRelationship } from './entities';
22
39
  import type {
40
+ Commitment,
41
+ CommitmentApi,
42
+ CommitmentQuery,
23
43
  CorpusApi,
24
44
  CorpusPage,
25
45
  CorpusScope,
26
46
  DocumentQuery,
27
47
  MoneyEvent,
28
48
  Party,
29
- SourceDocument,
49
+ PartyRef,
30
50
  SourceFreshness,
31
51
  } from './types';
32
52
 
33
- /** The `@startsimpli/api` client surface this adapter needs, and no more. */
53
+ /**
54
+ * The `@startsimpli/api` client surface this adapter needs, and no more.
55
+ *
56
+ * `post` is OPTIONAL: reading the corpus is the common case and a deployment
57
+ * that only reads should not have to supply a write method it never calls. The
58
+ * commitment write path checks for it and reports honestly when it is absent,
59
+ * rather than failing at the moment somebody presses a button.
60
+ */
34
61
  export interface CorpusHttp {
35
62
  get<T>(path: string, options?: { params?: Record<string, unknown> }): Promise<T>;
63
+ post?<T>(path: string, body?: unknown, options?: { params?: Record<string, unknown> }): Promise<T>;
36
64
  }
37
65
 
38
66
  export interface CreateCorpusApiOptions {
@@ -47,11 +75,36 @@ export interface CreateCorpusApiOptions {
47
75
  * Which `DocumentQuery` keys this deployment's API really applies. Anything
48
76
  * not listed is reported to the user as unfiltered rather than assumed.
49
77
  */
50
- supportedFilters?: Array<keyof DocumentQuery>;
78
+ supportedFilters?: Array<keyof DocumentQuery | keyof CommitmentQuery>;
51
79
  /** Endpoint bases, without leading/trailing slashes (the client convention). */
52
- paths?: { entities?: string; connections?: string };
80
+ paths?: { entities?: string; connections?: string; relationships?: string };
53
81
  /** How old a source's last sync may be before the Library calls it stale. */
54
82
  staleAfterHours?: number;
83
+ /**
84
+ * Resolve people from the tenant's typed EDGES into `participants`.
85
+ *
86
+ * On by default because it is what the shipped pipeline writes: the corpus
87
+ * delivery lands `source_document --involves--> party` edges and a bare
88
+ * `participant_count` on the document (bd startsim-768w.38.18). A deployment
89
+ * whose connectors denormalize participants inline can turn this off and save
90
+ * the round trip; the inline array is read either way.
91
+ */
92
+ resolveParticipants?: boolean;
93
+ /** Which edge types mean "this party is on this record". Data, not an enum. */
94
+ participantEdges?: readonly string[];
95
+ /**
96
+ * Bound on the graph read, in PAGES.
97
+ *
98
+ * The tenant relationships endpoint has no `?source=` filter today
99
+ * (`tenant-starter/apps/api/views.py` — see also bd startsim-768w.38.12 for
100
+ * the same gap on entities), so the edges for a page of documents cannot be
101
+ * requested directly and the graph is read in bulk and cached. This cap is
102
+ * what keeps that bounded rather than unbounded: past it, participants simply
103
+ * stop resolving for the older tail instead of the page hanging.
104
+ */
105
+ maxGraphPages?: number;
106
+ /** How long a loaded graph is reused before it is read again. */
107
+ graphTtlMs?: number;
55
108
  }
56
109
 
57
110
  interface DrfPage<T> {
@@ -63,6 +116,9 @@ interface DrfPage<T> {
63
116
 
64
117
  const EMPTY_PAGE = { count: 0, next: null, previous: null, results: [] };
65
118
 
119
+ /** Rows written in parallel. Small enough not to look like a burst to the tenant. */
120
+ const WRITE_CONCURRENCY = 6;
121
+
66
122
  function params(query: DocumentQuery, type: string) {
67
123
  const out: Record<string, unknown> = { type, page: query.page ?? 1 };
68
124
  if (query.pageSize) out.page_size = query.pageSize;
@@ -77,7 +133,7 @@ function params(query: DocumentQuery, type: string) {
77
133
  /** Which of the filters the caller asked for are actually honoured server-side. */
78
134
  function appliedFilters(
79
135
  query: DocumentQuery,
80
- supported: Array<keyof DocumentQuery>,
136
+ supported: Array<string>,
81
137
  ): string[] {
82
138
  const requested = (['search', 'source', 'partyId', 'occurredAfter', 'occurredBefore'] as const).filter(
83
139
  (key) => Boolean(query[key]),
@@ -85,17 +141,22 @@ function appliedFilters(
85
141
  return requested.filter((key) => supported.includes(key));
86
142
  }
87
143
 
88
- export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi {
144
+ export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi & CommitmentApi {
89
145
  const {
90
146
  http,
91
147
  scope,
92
148
  sourceLabels = {},
93
149
  supportedFilters = [],
94
150
  staleAfterHours = 26,
151
+ resolveParticipants = true,
152
+ participantEdges = DEFAULT_PARTICIPANT_EDGES,
153
+ maxGraphPages = 20,
154
+ graphTtlMs = 30_000,
95
155
  } = options;
96
156
  const types: CorpusTypeKeys = { ...DEFAULT_TYPE_KEYS, ...options.typeKeys };
97
157
  const entities = options.paths?.entities ?? 'api/v1/entities';
98
158
  const connections = options.paths?.connections ?? 'api/v1/connections';
159
+ const relationships = options.paths?.relationships ?? 'api/v1/relationships';
99
160
 
100
161
  async function page<T>(
101
162
  type: string,
@@ -114,26 +175,88 @@ export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi {
114
175
  };
115
176
  }
116
177
 
178
+ /** Every page of a DRF list, up to the cap. Stops on the first failure. */
179
+ async function readAll<T>(path: string, extra: Record<string, unknown>): Promise<T[]> {
180
+ const rows: T[] = [];
181
+ for (let index = 1; index <= maxGraphPages; index += 1) {
182
+ const response = await http.get<DrfPage<T>>(path, { params: { ...extra, page: index } });
183
+ const results = response?.results ?? [];
184
+ rows.push(...results);
185
+ if (!response?.next || results.length === 0) break;
186
+ }
187
+ return rows;
188
+ }
189
+
190
+ // ---------------------------------------------------------------------
191
+ // the participant graph
192
+ // ---------------------------------------------------------------------
193
+ //
194
+ // ONE read for a page of documents, not one per document — and cached, so a
195
+ // screen that lists, opens a record and then asks a question spends it once.
196
+ // It FAILS SOFT: a tenant whose relationships endpoint is missing or refused
197
+ // renders exactly what it rendered before this existed, rather than an error
198
+ // where a Library used to be.
199
+ let graph: { at: number; value: Promise<Map<string, PartyRef[]>> } | null = null;
200
+
201
+ async function loadGraph(): Promise<Map<string, PartyRef[]>> {
202
+ const empty = new Map<string, PartyRef[]>();
203
+ if (!resolveParticipants) return empty;
204
+ try {
205
+ const partyRows = await readAll<TenantEntity>(entities, { type: types.party });
206
+ // No parties means no edge can resolve to a name, so the second read is
207
+ // pure cost — which is the common case on a freshly connected workspace.
208
+ if (partyRows.length === 0) return empty;
209
+ const parties = partyRows.map((entity) => toParty(entity, scope));
210
+ const edges = await readAll<TenantRelationship>(relationships, {});
211
+ return indexParticipants(edges, parties, participantEdges);
212
+ } catch {
213
+ return empty;
214
+ }
215
+ }
216
+
217
+ function participantGraph(): Promise<Map<string, PartyRef[]>> {
218
+ const now = Date.now();
219
+ if (!graph || now - graph.at > graphTtlMs) {
220
+ graph = { at: now, value: loadGraph() };
221
+ }
222
+ return graph.value;
223
+ }
224
+
117
225
  return {
118
- listDocuments(query) {
119
- return page<SourceDocument>(types.document, query, (entity) =>
120
- toSourceDocument(entity, scope, sourceLabels),
121
- );
226
+ async listDocuments(query) {
227
+ const documents = await page<TenantEntity>(types.document, query, (entity) => entity);
228
+ // Skip the graph entirely for an empty page: an empty corpus should cost
229
+ // exactly the one request it costs today.
230
+ const byRecord = documents.results.length ? await participantGraph() : new Map();
231
+ return {
232
+ ...documents,
233
+ results: documents.results.map((entity) =>
234
+ toSourceDocument(entity, scope, sourceLabels, byRecord.get(String(entity.id)) ?? []),
235
+ ),
236
+ };
122
237
  },
123
238
 
124
239
  async getDocument(id) {
125
- const entity = await http.get<TenantEntity>(`${entities}/${encodeURIComponent(id)}`);
126
- return toSourceDocument(entity, scope, sourceLabels);
240
+ const [entity, byRecord] = await Promise.all([
241
+ http.get<TenantEntity>(`${entities}/${encodeURIComponent(id)}`),
242
+ participantGraph(),
243
+ ]);
244
+ return toSourceDocument(entity, scope, sourceLabels, byRecord.get(String(entity.id)) ?? []);
127
245
  },
128
246
 
129
247
  listParties(query = {}) {
130
248
  return page<Party>(types.party, query, (entity) => toParty(entity, scope));
131
249
  },
132
250
 
133
- listMoneyEvents(query = {}) {
134
- return page<MoneyEvent>(types.moneyEvent, query, (entity) =>
135
- toMoneyEvent(entity, scope, sourceLabels),
136
- );
251
+ async listMoneyEvents(query = {}) {
252
+ const events = await page<TenantEntity>(types.moneyEvent, query, (entity) => entity);
253
+ const byRecord = events.results.length ? await participantGraph() : new Map();
254
+ return {
255
+ ...events,
256
+ results: events.results.map((entity) =>
257
+ toMoneyEvent(entity, scope, sourceLabels, byRecord.get(String(entity.id)) ?? []),
258
+ ),
259
+ };
137
260
  },
138
261
 
139
262
  /**
@@ -144,6 +267,71 @@ export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi {
144
267
  * Dropbox has contributed zero documents — and a count derived from the
145
268
  * corpus alone can never say it.
146
269
  */
270
+ /**
271
+ * The tracking surface's read (bd startsim-768w.38.25).
272
+ *
273
+ * SERVER-SIDE FILTERING, ALWAYS (CLAUDE.md rule 8) — status, owner and due
274
+ * window all go out as query parameters. The tenant does not honour them
275
+ * yet (bd startsim-768w.38.12 is the bead for that, and the filters that
276
+ * matter most for THIS screen are status/owner/dueBefore, not full text), so
277
+ * `appliedFilters` reports the gap and the composer says so on screen rather
278
+ * than rendering an unfiltered list under a filled-in control.
279
+ */
280
+ async listCommitments(query: CommitmentQuery = {}): Promise<CorpusPage<Commitment>> {
281
+ const statuses = Array.isArray(query.status) ? query.status : query.status ? [query.status] : [];
282
+ const response = await http.get<DrfPage<TenantEntity>>(entities, {
283
+ params: {
284
+ type: types.commitment,
285
+ page: query.page ?? 1,
286
+ ...(query.pageSize ? { page_size: query.pageSize } : {}),
287
+ ...(statuses.length ? { status: statuses.join(',') } : {}),
288
+ ...(query.subtype ? { subtype: query.subtype } : {}),
289
+ ...(query.owner ? { owner: query.owner } : {}),
290
+ ...(query.dueBefore ? { due_before: query.dueBefore } : {}),
291
+ ...(query.changedAfter ? { changed_after: query.changedAfter } : {}),
292
+ ...(query.search ? { search: query.search } : {}),
293
+ },
294
+ });
295
+ return {
296
+ count: response.count ?? response.results?.length ?? 0,
297
+ next: response.next ?? null,
298
+ previous: response.previous ?? null,
299
+ results: (response.results ?? []).map((entity) => toCommitment(entity, scope, sourceLabels)),
300
+ appliedFilters: (['status', 'subtype', 'owner', 'dueBefore', 'changedAfter', 'search'] as const)
301
+ .filter((key) => Boolean(query[key]))
302
+ .filter((key) => (supportedFilters as string[]).includes(key)),
303
+ };
304
+ },
305
+
306
+ /**
307
+ * Write a run's commitments back.
308
+ *
309
+ * ONE REQUEST PER ROW, because that is what the tenant offers: its
310
+ * `/api/v1/entities/upsert/` takes a single record (verified on the wire —
311
+ * a list body is rejected with `external_id: required for upsert`). The
312
+ * writes are therefore fanned out with a bounded concurrency rather than
313
+ * looped serially, and the missing bulk endpoint is filed rather than
314
+ * pretended away.
315
+ *
316
+ * Idempotency is the SERVER's, not this function's: the upsert is keyed on
317
+ * (entity_type, external_id), so a second run rewrites rows in place and two
318
+ * concurrent runs converge instead of duplicating.
319
+ */
320
+ async saveCommitments(commitments: Commitment[]): Promise<void> {
321
+ if (!http.post) {
322
+ throw new Error('This corpus client is read-only — no write method was supplied.');
323
+ }
324
+ const post = http.post.bind(http);
325
+ const payloads = commitments.map(commitmentToEntity);
326
+ for (let index = 0; index < payloads.length; index += WRITE_CONCURRENCY) {
327
+ await Promise.all(
328
+ payloads
329
+ .slice(index, index + WRITE_CONCURRENCY)
330
+ .map((payload) => post(`${entities}/upsert`, payload)),
331
+ );
332
+ }
333
+ },
334
+
147
335
  async listSources(): Promise<SourceFreshness[]> {
148
336
  const [connectionPage, documentPage] = await Promise.all([
149
337
  http