@startsimpli/integrations 0.2.5 → 0.2.7

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.7",
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');
@@ -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 {