@startsimpli/integrations 0.2.5 → 0.2.9

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.5",
3
+ "version": "0.2.9",
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",
@@ -507,6 +507,53 @@ describe('extractCommitments', () => {
507
507
  expect(second.nextOffset).toBeNull();
508
508
  });
509
509
 
510
+ it('writes only what it CHANGED, not the whole corpus', async () => {
511
+ // Not a micro-optimisation. The tenant has no bulk upsert, so every row is a
512
+ // request; rewriting untouched rows makes a one-document slice cost one
513
+ // request per row in the workspace. On the live tenant that pushed a
514
+ // single-document run past the 60-second gateway at ninety-eight rows.
515
+ const corpus = createFixtureCorpusApi({ scope: TANDEM, documents: FIXTURE_TRACKED_DOCUMENTS });
516
+ const tracked: Commitment[] = [];
517
+ const writes: number[] = [];
518
+ (corpus as { listCommitments?: unknown }).listCommitments = async () => ({
519
+ count: tracked.length, next: null, previous: null, results: tracked,
520
+ });
521
+ (corpus as { saveCommitments?: unknown }).saveCommitments = async (items: Commitment[]) => {
522
+ writes.push(items.length);
523
+ const byId = new Map(tracked.map((item) => [item.externalId, item]));
524
+ for (const item of items) byId.set(item.externalId, item);
525
+ tracked.splice(0, tracked.length, ...byId.values());
526
+ };
527
+ const extractor = createFixtureExtractor();
528
+
529
+ await extractCommitments(
530
+ { scope: TANDEM, documentIds: [FIXTURE_TRACKED_DOCUMENTS[0].id] },
531
+ { corpus, extractor },
532
+ );
533
+ const afterFirst = tracked.length;
534
+ await extractCommitments(
535
+ { scope: TANDEM, documentIds: [FIXTURE_TRACKED_DOCUMENTS[1].id] },
536
+ { corpus, extractor },
537
+ );
538
+
539
+ // The second slice never rewrites the first slice's untouched rows.
540
+ expect(writes[1]).toBeLessThan(tracked.length);
541
+ expect(afterFirst).toBeGreaterThan(0);
542
+ });
543
+
544
+ it('marks NOTHING dirty when a pass changes nothing', () => {
545
+ // The guarantee stated where it is unambiguous — on the pure reconciler,
546
+ // with no extractor in the way. Identical input, second time: zero writes.
547
+ const drafts = mergeDrafts(FIXTURE_TRACKED_DOCUMENTS.flatMap(rulesExtract));
548
+ const context = { scope: TANDEM, documents: FIXTURE_TRACKED_DOCUMENTS };
549
+ const first = reconcile([], drafts, context);
550
+ expect(first.touched.length).toBe(first.commitments.length);
551
+
552
+ const second = reconcile(first.commitments, drafts, context);
553
+ expect(second.created).toBe(0);
554
+ expect(second.touched).toEqual([]);
555
+ });
556
+
510
557
  it('refuses outright when the session carries no workspace claim', async () => {
511
558
  const corpus = createFixtureCorpusApi({ leakScope: true, documents: FIXTURE_TRACKED_DOCUMENTS });
512
559
  const listDocuments = vi.spyOn(corpus, 'listDocuments');
@@ -299,3 +299,77 @@ describe('the tenant’s filters are still not honoured, and are still not faked
299
299
  expect(page.count).toBe(FIXTURE_TENANT_DOCUMENTS.length);
300
300
  });
301
301
  });
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // the tracker's read, against the tenant's real filter vocabulary
305
+ // ---------------------------------------------------------------------------
306
+
307
+ describe('listCommitments speaks the tenant data API’s own filter language', () => {
308
+ function client(applied: string[] | undefined) {
309
+ const seen: Array<{ path: string; params: Record<string, unknown> }> = [];
310
+ const api = createCorpusApi({
311
+ http: {
312
+ async get<T>(path: string, options?: { params?: Record<string, unknown> }): Promise<T> {
313
+ seen.push({ path, params: options?.params ?? {} });
314
+ return {
315
+ count: 0, next: null, previous: null, results: [],
316
+ ...(applied ? { applied_filters: applied, ignored_filters: [] } : {}),
317
+ } as T;
318
+ },
319
+ },
320
+ scope: { orgId: 'tandem' },
321
+ supportedFilters: [],
322
+ });
323
+ return { api, seen };
324
+ }
325
+
326
+ it('sends status, owner, due window and order as attr.* parameters', async () => {
327
+ const { api, seen } = client(['attr.status__in']);
328
+ await api.listCommitments({
329
+ status: ['open', 'blocked'],
330
+ owner: 'Kate',
331
+ dueBefore: '2026-08-10T00:00:00.000Z',
332
+ changedAfter: '2026-08-03T00:00:00.000Z',
333
+ pageSize: 200,
334
+ });
335
+ expect(seen[0].params).toMatchObject({
336
+ type: 'commitment',
337
+ 'attr.status__in': 'open,blocked',
338
+ 'attr.owner_name__icontains': 'Kate',
339
+ 'attr.due_at__lte': '2026-08-10T00:00:00.000Z',
340
+ 'attr.last_seen_at__gte': '2026-08-03T00:00:00.000Z',
341
+ order: 'attr.due_at',
342
+ page_size: 200,
343
+ });
344
+ });
345
+
346
+ it('caps a text filter at the length the server documents', async () => {
347
+ const { api, seen } = client([]);
348
+ await api.listCommitments({ owner: 'x'.repeat(900), search: 'y'.repeat(900) });
349
+ expect(String(seen[0].params['attr.owner_name__icontains'])).toHaveLength(500);
350
+ expect(String(seen[0].params.search)).toHaveLength(500);
351
+ });
352
+
353
+ it('caps page size at the server’s maximum rather than asking for more', async () => {
354
+ const { api, seen } = client([]);
355
+ await api.listCommitments({ pageSize: 5000 });
356
+ expect(seen[0].params.page_size).toBe(200);
357
+ });
358
+
359
+ it('believes the SERVER about what it filtered, not a hardcoded list', async () => {
360
+ // The static `supportedFilters` says nothing is supported. The server says
361
+ // it applied status. The server wins — otherwise the screen keeps apologising
362
+ // for a gap that was closed while it was not looking.
363
+ const { api } = client(['attr.status__in', 'attr.due_at__lte']);
364
+ const page = await api.listCommitments({ status: ['open', 'blocked'], dueBefore: 'x', owner: 'Kate' });
365
+ expect(page.appliedFilters).toEqual(['status', 'dueBefore']);
366
+ // And owner is NOT claimed, because the server did not report it.
367
+ expect(page.appliedFilters).not.toContain('owner');
368
+ });
369
+
370
+ it('falls back to the declared list on a server that does not report', async () => {
371
+ const { api } = client(undefined);
372
+ const page = await api.listCommitments({ status: 'open' });
373
+ expect(page.appliedFilters).toEqual([]);
374
+ });
375
+ });
@@ -7,6 +7,7 @@
7
7
  * as though it had one, a filter that did nothing, a row with no traceable
8
8
  * source, a run that closed something and did not say why.
9
9
  */
10
+ import * as React from 'react';
10
11
  import { render, screen, within } from '@testing-library/react';
11
12
  import userEvent from '@testing-library/user-event';
12
13
  import { describe, expect, it, vi } from 'vitest';
@@ -92,6 +93,61 @@ function makeApi(options: {
92
93
  };
93
94
  }
94
95
 
96
+ describe('TrackerPage — it must not loop', () => {
97
+ /**
98
+ * SHIPPED TO PRODUCTION AND CAUGHT BY A HUMAN, not by this suite: 917
99
+ * `entities` requests in ten minutes on the live tenant, the screen visibly
100
+ * flashing. The cause was three lines that each looked reasonable:
101
+ *
102
+ * now = Date.now() a defaulted prop — a NEW value per render
103
+ * useMemo(..., [tab, owner, now]) so a new query object per render
104
+ * useCallback(..., [api, query]) so a new loader per render
105
+ * useEffect(..., [load]) so a fetch per render -> setState -> render
106
+ *
107
+ * A defaulted `Date.now()` can never be a stable dependency. These tests count
108
+ * requests, because that is the only thing that catches this: at 1.5 requests
109
+ * a second the screenshot looks perfect.
110
+ */
111
+ it('fetches ONCE on mount and then stops', async () => {
112
+ let calls = 0;
113
+ const api = makeApi({ onQuery: () => { calls += 1; } });
114
+ render(<TrackerPage api={api} scope={SCOPE} />);
115
+ await screen.findByText('Chase the inventor for comments on 2148');
116
+ await new Promise((resolve) => setTimeout(resolve, 250));
117
+ expect(calls).toBe(1);
118
+ });
119
+
120
+ it('does not loop even when the app hands it a NEW api object every render', async () => {
121
+ // The composer takes an injected capability. A caller that builds it inline
122
+ // is a mistake, but it must not be a runaway one — the identity of an api is
123
+ // not a reason to re-read the same query.
124
+ let calls = 0;
125
+ function Harness() {
126
+ const [, force] = React.useState(0);
127
+ React.useEffect(() => {
128
+ const timer = setInterval(() => force((value) => value + 1), 20);
129
+ return () => clearInterval(timer);
130
+ }, []);
131
+ // Deliberately unmemoized.
132
+ return <TrackerPage api={makeApi({ onQuery: () => { calls += 1; } })} scope={SCOPE} />;
133
+ }
134
+ render(<Harness />);
135
+ await screen.findByText('Chase the inventor for comments on 2148');
136
+ await new Promise((resolve) => setTimeout(resolve, 300));
137
+ expect(calls).toBe(1);
138
+ });
139
+
140
+ it('DOES re-read when the user actually changes something', async () => {
141
+ let calls = 0;
142
+ render(<TrackerPage api={makeApi({ onQuery: () => { calls += 1; } })} scope={SCOPE} now={NOW} />);
143
+ await screen.findByText('Chase the inventor for comments on 2148');
144
+ expect(calls).toBe(1);
145
+ await userEvent.click(screen.getByRole('tab', { name: 'Overdue' }));
146
+ await new Promise((resolve) => setTimeout(resolve, 150));
147
+ expect(calls).toBe(2);
148
+ });
149
+ });
150
+
95
151
  describe('TrackerPage — what is outstanding', () => {
96
152
  it('shows open items and hides closed ones', async () => {
97
153
  render(<TrackerPage api={makeApi()} scope={SCOPE} now={NOW} />);
@@ -116,7 +172,7 @@ describe('TrackerPage — what is outstanding', () => {
116
172
  // The Library's `appliedFilters` contract, owed here about status — a
117
173
  // status filter that silently does nothing breaks the screen's only claim.
118
174
  expect(
119
- await screen.findByText(/does not filter by .*status/i),
175
+ await screen.findByText(/did not filter by .*status/i),
120
176
  ).toBeInTheDocument();
121
177
  });
122
178
 
@@ -129,7 +185,7 @@ describe('TrackerPage — what is outstanding', () => {
129
185
  />,
130
186
  );
131
187
  await screen.findByText('Chase the inventor for comments on 2148');
132
- expect(screen.queryByText(/does not filter by/i)).not.toBeInTheDocument();
188
+ expect(screen.queryByText(/did not filter by/i)).not.toBeInTheDocument();
133
189
  });
134
190
 
135
191
  it('puts an overdue item above one with no due date', async () => {
@@ -41,7 +41,15 @@ export interface TrackerPageProps {
41
41
  scope: CorpusScope;
42
42
  /** Click-through from a mention to the record, normally the Library route. */
43
43
  onOpenDocument?: (documentId: string) => void;
44
- /** Injectable clock, so "overdue" and "this week" are testable. */
44
+ /**
45
+ * Injectable clock, so "overdue" and "this week" are testable.
46
+ *
47
+ * NOT DEFAULTED IN THE SIGNATURE. `now = Date.now()` is a new value on every
48
+ * render, which makes every memo below it new, which makes the load effect
49
+ * fire on every render, which sets state, which renders. That shipped, and it
50
+ * cost 917 requests in ten minutes on a live tenant. When the caller does not
51
+ * supply a clock, one is frozen ONCE for the life of the component.
52
+ */
45
53
  now?: number;
46
54
  classNames?: Partial<Record<TrackerSlot, string>>;
47
55
  }
@@ -79,11 +87,24 @@ export function TrackerPage({
79
87
  api,
80
88
  scope,
81
89
  onOpenDocument,
82
- now = Date.now(),
90
+ now,
83
91
  classNames = {},
84
92
  }: TrackerPageProps) {
85
93
  const cn = { ...DEFAULTS, ...classNames };
86
94
 
95
+ // Frozen once. See the note on the prop.
96
+ const [mountedAt] = React.useState(() => Date.now());
97
+ const clock = now ?? mountedAt;
98
+
99
+ /**
100
+ * The api is an injected CAPABILITY, not data, so its identity is not a reason
101
+ * to re-read the same query — and an app that builds it inline must not turn
102
+ * that mistake into a runaway. Held in a ref and read at call time; a genuine
103
+ * change of workspace re-reads through `scope.orgId` below.
104
+ */
105
+ const apiRef = React.useRef(api);
106
+ apiRef.current = api;
107
+
87
108
  const [tab, setTab] = React.useState<'open' | 'overdue' | 'recent' | 'done'>('open');
88
109
  const [owner, setOwner] = React.useState('');
89
110
  const [rows, setRows] = React.useState<Commitment[]>([]);
@@ -101,18 +122,18 @@ export function TrackerPage({
101
122
  // and the banner alone would be doing all the work.
102
123
  status: tab === 'done' ? 'done' : (['open', 'blocked'] as CommitmentStatus[]),
103
124
  ...(owner.trim() ? { owner: owner.trim() } : {}),
104
- ...(tab === 'recent' ? { changedAfter: new Date(now - WEEK_MS).toISOString() } : {}),
105
- ...(tab === 'overdue' ? { dueBefore: new Date(now).toISOString() } : {}),
125
+ ...(tab === 'recent' ? { changedAfter: new Date(clock - WEEK_MS).toISOString() } : {}),
126
+ ...(tab === 'overdue' ? { dueBefore: new Date(clock).toISOString() } : {}),
106
127
  pageSize: 200,
107
128
  }),
108
- [tab, owner, now],
129
+ [tab, owner, clock],
109
130
  );
110
131
 
111
132
  const load = React.useCallback(async () => {
112
133
  setLoading(true);
113
134
  setError('');
114
135
  try {
115
- const page = await api.listCommitments(query);
136
+ const page = await apiRef.current.listCommitments(query);
116
137
  setRows(page.results);
117
138
  setApplied(page.appliedFilters ?? []);
118
139
  } catch (err) {
@@ -121,35 +142,36 @@ export function TrackerPage({
121
142
  } finally {
122
143
  setLoading(false);
123
144
  }
124
- }, [api, query]);
145
+ }, [query]);
125
146
 
126
- React.useEffect(() => { void load(); }, [load]);
147
+ // Real input only: the query the user asked for, and the workspace they are in.
148
+ React.useEffect(() => { void load(); }, [load, scope.orgId]);
127
149
 
128
150
  /**
129
151
  * The tab, applied in the browser as well as sent to the server.
130
152
  *
131
- * NOT a violation of rule 8 — the query still goes out as parameters and the
132
- * server still pages. This is the honest belt to the server's braces: until bd
133
- * startsim-768w.38.12 teaches the tenant API `?status=`, the API returns
134
- * everything, and a screen that showed closed items under "Outstanding" would
135
- * be lying about the one thing it exists to say. The banner below states that
136
- * the list is not server-filtered, so the user knows the count is over what
137
- * this page could fetch rather than over the whole corpus.
153
+ * NOT a violation of rule 8 — the query goes out as `attr.*` parameters and
154
+ * the server pages and filters. This is the belt to the server's braces: a
155
+ * deployment whose API ignores one of them must not end up rendering closed
156
+ * items under "Outstanding", which is the one thing this screen exists to say.
157
+ * Re-applying a filter the server already applied is a no-op; not re-applying
158
+ * one it ignored is a lie. The banner below fires only when the SERVER itself
159
+ * reports it did not apply something.
138
160
  */
139
161
  const visible = React.useMemo(() => {
140
162
  const byTab = rows.filter((item) => {
141
163
  if (tab === 'done') return item.status === 'done' || item.status === 'dropped';
142
164
  if (item.status !== 'open' && item.status !== 'blocked') return false;
143
- if (tab === 'overdue') return isOverdue(item, now);
144
- if (tab === 'recent') return changedSince(item, now - WEEK_MS);
165
+ if (tab === 'overdue') return isOverdue(item, clock);
166
+ if (tab === 'recent') return changedSince(item, clock - WEEK_MS);
145
167
  return true;
146
168
  });
147
169
  const needle = owner.trim().toLowerCase();
148
170
  const byOwner = needle
149
171
  ? byTab.filter((item) => (item.ownerName ?? '').toLowerCase().includes(needle))
150
172
  : byTab;
151
- return [...byOwner].sort(compareForScreen(now));
152
- }, [rows, tab, owner, now]);
173
+ return [...byOwner].sort(compareForScreen(clock));
174
+ }, [rows, tab, owner, clock]);
153
175
 
154
176
  const unfiltered = (['status', 'owner', 'dueBefore', 'changedAfter'] as const).filter(
155
177
  (key) => Boolean(query[key]) && !applied.includes(key),
@@ -191,7 +213,7 @@ export function TrackerPage({
191
213
  setRunning(true);
192
214
  setError('');
193
215
  try {
194
- setRun(await api.extract!({ scope }));
216
+ setRun(await apiRef.current.extract!({ scope }));
195
217
  await load();
196
218
  } catch (err) {
197
219
  setError(failureMessage(err));
@@ -210,7 +232,7 @@ export function TrackerPage({
210
232
  // status — and it matters more here, because "outstanding" is the
211
233
  // claim the whole screen makes.
212
234
  <p className={cn.warning} role="status">
213
- This workspace’s data API does not filter by {unfiltered.join(', ')} yet, so this list was
235
+ This workspace’s data API did not filter by {unfiltered.join(', ')}, so this list was
214
236
  narrowed in the browser over the records it could fetch. Counts are over that set, not
215
237
  over the whole corpus.
216
238
  </p>
@@ -237,7 +259,7 @@ export function TrackerPage({
237
259
  <CommitmentRow
238
260
  key={item.externalId}
239
261
  item={item}
240
- now={now}
262
+ now={clock}
241
263
  classNames={cn}
242
264
  onOpenDocument={onOpenDocument}
243
265
  />
@@ -114,6 +114,70 @@ interface DrfPage<T> {
114
114
  results: T[];
115
115
  }
116
116
 
117
+ /**
118
+ * What the tenant data API reports back about the query it was handed.
119
+ *
120
+ * `applied_filters` and `ignored_filters` are the whole reason a composer can
121
+ * stop guessing: a screen that narrows its own list has to know whether it is
122
+ * doing so because the server could not, or is duplicating work the server
123
+ * already did. Reported per response, so a capability that lands mid-week is
124
+ * picked up without a client deploy.
125
+ */
126
+ interface ServerFilterReport {
127
+ applied_filters?: string[];
128
+ ignored_filters?: string[];
129
+ search_backend?: string;
130
+ }
131
+
132
+ /**
133
+ * `CommitmentQuery` -> the tenant's own parameter vocabulary.
134
+ *
135
+ * Attribute filters are `attr.<declared name>` with an optional operator suffix,
136
+ * which is why every field the tracker filters on has to be a DECLARED
137
+ * AttributeDef — an undeclared attribute is invisible to the index and the
138
+ * server reports it under `ignored_filters` rather than silently returning
139
+ * everything.
140
+ */
141
+ const TEXT_FILTER_MAX = 500;
142
+
143
+ function commitmentParams(query: CommitmentQuery, type: string): Record<string, unknown> {
144
+ const statuses = Array.isArray(query.status) ? query.status : query.status ? [query.status] : [];
145
+ const out: Record<string, unknown> = {
146
+ type,
147
+ page: query.page ?? 1,
148
+ ...(query.pageSize ? { page_size: Math.min(query.pageSize, 200) } : {}),
149
+ // Oldest debt first, which is what the screen sorts by anyway — asking the
150
+ // server for it means page one is the right page one.
151
+ order: 'attr.due_at',
152
+ };
153
+ if (statuses.length === 1) out['attr.status'] = statuses[0];
154
+ else if (statuses.length > 1) out['attr.status__in'] = statuses.join(',');
155
+ if (query.subtype) out['attr.subtype'] = query.subtype;
156
+ if (query.owner) out['attr.owner_name__icontains'] = query.owner.slice(0, TEXT_FILTER_MAX);
157
+ if (query.dueBefore) out['attr.due_at__lte'] = query.dueBefore;
158
+ if (query.changedAfter) out['attr.last_seen_at__gte'] = query.changedAfter;
159
+ if (query.search) out.search = query.search.slice(0, TEXT_FILTER_MAX);
160
+ return out;
161
+ }
162
+
163
+ /** Which server parameter carries which `CommitmentQuery` key. */
164
+ const FILTER_PARAMS: Array<[keyof CommitmentQuery, string[]]> = [
165
+ ['status', ['attr.status', 'attr.status__in']],
166
+ ['subtype', ['attr.subtype']],
167
+ ['owner', ['attr.owner_name__icontains']],
168
+ ['dueBefore', ['attr.due_at__lte']],
169
+ ['changedAfter', ['attr.last_seen_at__gte']],
170
+ ['search', ['search']],
171
+ ];
172
+
173
+ /** Translate the server's report back into the composer's vocabulary. */
174
+ function reportedFilters(query: CommitmentQuery, applied: string[]): string[] {
175
+ const server = new Set(applied);
176
+ return FILTER_PARAMS.filter(
177
+ ([key, params]) => Boolean(query[key]) && params.some((param) => server.has(param)),
178
+ ).map(([key]) => key);
179
+ }
180
+
117
181
  const EMPTY_PAGE = { count: 0, next: null, previous: null, results: [] };
118
182
 
119
183
  /** Rows written in parallel. Small enough not to look like a burst to the tenant. */
@@ -278,28 +342,22 @@ export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi & Co
278
342
  * than rendering an unfiltered list under a filled-in control.
279
343
  */
280
344
  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
- });
345
+ const params = commitmentParams(query, types.commitment);
346
+ const response = await http.get<DrfPage<TenantEntity> & ServerFilterReport>(entities, { params });
295
347
  return {
296
348
  count: response.count ?? response.results?.length ?? 0,
297
349
  next: response.next ?? null,
298
350
  previous: response.previous ?? null,
299
351
  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)),
352
+ // THE SERVER'S OWN ANSWER WINS. `applied_filters` is reported by the
353
+ // tenant data API (capabilities.entities.reports_applied_filters), so
354
+ // the screen no longer has to be told out-of-band what the backend can
355
+ // do — and cannot claim a gap that has since been closed. The static
356
+ // `supportedFilters` list is only the fallback for a deployment old
357
+ // enough not to report.
358
+ appliedFilters: response.applied_filters
359
+ ? reportedFilters(query, response.applied_filters)
360
+ : appliedFilters(query as DocumentQuery, supportedFilters as string[]),
303
361
  };
304
362
  },
305
363
 
@@ -246,6 +246,15 @@ export interface ReconcileResult {
246
246
  commitments: Commitment[];
247
247
  created: number;
248
248
  merged: number;
249
+ /**
250
+ * The `externalId`s this pass actually changed.
251
+ *
252
+ * A run must write what it CHANGED, not what it holds. Rewriting the whole
253
+ * corpus on every pass is one request per row per slice — O(corpus) writes for
254
+ * O(slice) work — and on the live tenant that alone pushed a single-document
255
+ * run past the 60-second gateway once there were ninety-eight rows.
256
+ */
257
+ touched: string[];
249
258
  }
250
259
 
251
260
  export function reconcile(
@@ -255,6 +264,7 @@ export function reconcile(
255
264
  ): ReconcileResult {
256
265
  const byId = new Map(context.documents.map((document) => [document.id, document]));
257
266
  const out = existing.map(cloneCommitment);
267
+ const touched = new Set<string>();
258
268
  let created = 0;
259
269
  let merged = 0;
260
270
 
@@ -269,6 +279,7 @@ export function reconcile(
269
279
 
270
280
  if (match) {
271
281
  merged += 1;
282
+ const before = signature(match);
272
283
  applyMentions(match, mentions);
273
284
  // The extractor NEVER overwrites a status, an owner or a due date that is
274
285
  // already recorded. A person ticking something off on the screen must
@@ -276,6 +287,11 @@ export function reconcile(
276
287
  if (!match.ownerName && lead.ownerName) match.ownerName = lead.ownerName;
277
288
  if (!match.dueAt && lead.dueAt) match.dueAt = lead.dueAt;
278
289
  if (!match.detail && lead.detail) match.detail = lead.detail;
290
+ // Only a row that ACTUALLY changed is written. Marking every match dirty
291
+ // would make a repeat run rewrite the corpus for no reason, which is the
292
+ // difference between "running it twice is a no-op" as a claim and as a
293
+ // fact you can watch on the network tab.
294
+ if (signature(match) !== before) touched.add(match.externalId);
279
295
  continue;
280
296
  }
281
297
 
@@ -308,9 +324,10 @@ export function reconcile(
308
324
  };
309
325
  applyMentions(fresh, mentions);
310
326
  out.push(fresh);
327
+ touched.add(fresh.externalId);
311
328
  }
312
329
 
313
- return { commitments: out, created, merged };
330
+ return { commitments: out, created, merged, touched: [...touched] };
314
331
  }
315
332
 
316
333
  /**
@@ -335,6 +352,22 @@ function applyMentions(commitment: Commitment, mentions: CommitmentMention[]) {
335
352
 
336
353
  const mentionKey = (mention: CommitmentMention) => `${mention.documentId}::${mention.originKey}`;
337
354
 
355
+ /** Everything a re-run could legitimately change, as one comparable string. */
356
+ function signature(commitment: Commitment): string {
357
+ return [
358
+ commitment.title,
359
+ commitment.detail,
360
+ commitment.status,
361
+ commitment.statusReason ?? '',
362
+ commitment.ownerName ?? '',
363
+ commitment.dueAt ?? '',
364
+ commitment.resolvedBy ?? '',
365
+ commitment.firstSeenAt ?? '',
366
+ commitment.lastSeenAt ?? '',
367
+ commitment.mentions.map((mention) => `${mentionKey(mention)}|${mention.quote}`).join('~'),
368
+ ].join('\u0000');
369
+ }
370
+
338
371
  function toMention(draft: CommitmentDraft, document: SourceDocument): CommitmentMention {
339
372
  return {
340
373
  documentId: draft.documentId,
@@ -361,6 +394,8 @@ function identityOf(commitment: Commitment): CommitmentIdentity {
361
394
 
362
395
  export interface ResolutionResult {
363
396
  commitments: Commitment[];
397
+ /** The `externalId`s this pass closed. Same reasoning as `ReconcileResult`. */
398
+ touched: string[];
364
399
  resolved: number;
365
400
  needsReview: Array<{ commitmentId: string; documentId: string; quote: string }>;
366
401
  }
@@ -389,6 +424,7 @@ export function applyResolutions(
389
424
  const byId = new Map(context.documents.map((document) => [document.id, document]));
390
425
  const out = commitments.map(cloneCommitment);
391
426
  const needsReview: ResolutionResult['needsReview'] = [];
427
+ const touched = new Set<string>();
392
428
  let resolved = 0;
393
429
 
394
430
  for (const claim of claims) {
@@ -421,6 +457,7 @@ export function applyResolutions(
421
457
  best.commitment.statusReason = claim.quote || claim.statement;
422
458
  best.commitment.resolvedBy = claim.documentId;
423
459
  best.commitment.resolvedAt = at;
460
+ touched.add(best.commitment.externalId);
424
461
  resolved += 1;
425
462
  } else {
426
463
  // ONE review row per claim — its single best candidate. Listing every open
@@ -433,7 +470,7 @@ export function applyResolutions(
433
470
  }
434
471
  }
435
472
 
436
- return { commitments: out, resolved, needsReview };
473
+ return { commitments: out, touched: [...touched], resolved, needsReview };
437
474
  }
438
475
 
439
476
  // ---------------------------------------------------------------------------
@@ -349,9 +349,16 @@ export async function extractCommitments(
349
349
  // 6. resolutions, against the documents the scope guard approved and nothing else.
350
350
  const resolutions = applyResolutions(reconciled.commitments, claims, { documents });
351
351
 
352
- // 7. write. ONE call carrying every row, keyed on `external_id`.
353
- if (corpus.saveCommitments) {
354
- await corpus.saveCommitments(resolutions.commitments);
352
+ // 7. write WHAT CHANGED, keyed on `external_id`.
353
+ //
354
+ // Not the whole corpus. The tenant has no bulk upsert (a list body is
355
+ // rejected — see bd startsim-768w.38.12), so every row is a request, and
356
+ // rewriting untouched rows makes a one-document slice cost one request per row
357
+ // in the workspace. On the live tenant that took a single-document run past
358
+ // the 60-second gateway once there were ninety-eight of them.
359
+ const changed = new Set([...reconciled.touched, ...resolutions.touched]);
360
+ if (corpus.saveCommitments && changed.size) {
361
+ await corpus.saveCommitments(resolutions.commitments.filter((item) => changed.has(item.externalId)));
355
362
  }
356
363
 
357
364
  return {