@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.
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/corpus/extract.ts
CHANGED
|
@@ -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
|
|
353
|
-
|
|
354
|
-
|
|
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 {
|