@framers/agentos-ext-account-sources 0.7.2 → 0.8.0
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/README.md +12 -11
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/sync/AccountSourceSync.d.ts +17 -8
- package/dist/sync/AccountSourceSync.d.ts.map +1 -1
- package/dist/sync/AccountSourceSync.js +43 -14
- package/dist/sync/AccountSourceSync.js.map +1 -1
- package/dist/sync/SourceRevocations.d.ts +37 -3
- package/dist/sync/SourceRevocations.d.ts.map +1 -1
- package/dist/sync/SourceRevocations.js +86 -2
- package/dist/sync/SourceRevocations.js.map +1 -1
- package/dist/sync/SqlAccountSourceSyncStore.d.ts +105 -16
- package/dist/sync/SqlAccountSourceSyncStore.d.ts.map +1 -1
- package/dist/sync/SqlAccountSourceSyncStore.js +257 -45
- package/dist/sync/SqlAccountSourceSyncStore.js.map +1 -1
- package/dist/sync/schema.d.ts +4 -3
- package/dist/sync/schema.d.ts.map +1 -1
- package/dist/sync/schema.js +4 -3
- package/dist/sync/schema.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +1 -1
|
@@ -8,6 +8,19 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
8
8
|
import { SqlSourceDerivationStore, createSourceDerivation, } from '@framers/agentos/cognition/library';
|
|
9
9
|
import { createSourceSyncState, transitionSourceSync, sourceSyncDue, openSourceSyncCycle, advanceSourceSyncPage, committableSourceCursor, assertSourceSyncPolicy, SourceSyncFault, } from './state.js';
|
|
10
10
|
import { sourceSyncTables } from './schema.js';
|
|
11
|
+
/**
|
|
12
|
+
* Thrown by `expire` when it fails after removing at least one source. Each removal committed in a transaction of its
|
|
13
|
+
* own, so `partial` counts them in `removed`, with the failure itself in `failed`; `cause` holds the failure, whose
|
|
14
|
+
* message this error carries.
|
|
15
|
+
*/
|
|
16
|
+
export class SourceSyncExpiryError extends Error {
|
|
17
|
+
partial;
|
|
18
|
+
constructor(removed, cause) {
|
|
19
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
|
20
|
+
this.name = 'SourceSyncExpiryError';
|
|
21
|
+
this.partial = { removed, failed: 1 };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
11
24
|
class StaleSync extends Error {
|
|
12
25
|
constructor() { super('stale_source_sync'); }
|
|
13
26
|
}
|
|
@@ -17,12 +30,37 @@ const validId = (s) => typeof s === 'string' && s.length > 0 && s.length <= 512;
|
|
|
17
30
|
const skipReasons = ["not_permitted", "no_store", "revalidation_required", "invalid_cache_header", "invalid_bounds", "expired", "excluded", "out_of_window", "excluded_label", "no_eligible_content", "response_too_large", "structure_limit", "unsupported_charset", "invalid_encoding", "document_limit", "held_limit", "indexing_limit"];
|
|
18
31
|
const newest = (a, b) => b.activityAt - a.activityAt || (a.dependency.sourceId < b.dependency.sourceId ? -1
|
|
19
32
|
: a.dependency.sourceId > b.dependency.sourceId ? 1 : 0);
|
|
33
|
+
/** Whether a retained copy may still be held: before its expiry, with every message inside the policy's window. */
|
|
34
|
+
const retainable = (doc, policy, now) => doc.dependency.expiresAt > now && !doc.messages.some((m) => m.receivedAt + policy.windowMs <= now);
|
|
35
|
+
/** Whether two spans place the same message at the same offsets of the text, with the same receive time and cut. */
|
|
36
|
+
const sameSpan = (a, b) => a.messageId === b.messageId && a.start === b.start && a.end === b.end
|
|
37
|
+
&& a.receivedAt === b.receivedAt && a.truncated === b.truncated;
|
|
38
|
+
/** SHA-256 of a sanitized text in lower-case hex. */
|
|
39
|
+
const textHashOf = (text) => createHash('sha256').update(text).digest('hex');
|
|
40
|
+
/**
|
|
41
|
+
* Whether a read repeats the retained copy's text: the same text hash, length, activity time and message spans, so it
|
|
42
|
+
* adds nothing to index. For a copy an earlier release wrote, which has no text hash, the version and content hash
|
|
43
|
+
* stand in for it.
|
|
44
|
+
*/
|
|
45
|
+
const sameText = (stored, read) => (stored.textHash !== undefined ? stored.textHash === read.textHash
|
|
46
|
+
: stored.dependency.version === read.dependency.version && stored.contentHash === read.contentHash)
|
|
47
|
+
&& stored.characters === read.characters && stored.activityAt === read.activityAt
|
|
48
|
+
&& stored.messages.length === read.messages.length
|
|
49
|
+
&& stored.messages.every((message, index) => sameSpan(message, read.messages[index]));
|
|
50
|
+
/**
|
|
51
|
+
* Whether a read holds exactly the retained copy: its text (`sameText`) at the same version and content hash. Only the
|
|
52
|
+
* expiry and the message windows, which each read decides afresh, may differ.
|
|
53
|
+
*/
|
|
54
|
+
const sameCopy = (stored, read) => sameText(stored, read) && stored.dependency.version === read.dependency.version
|
|
55
|
+
&& stored.contentHash === read.contentHash;
|
|
20
56
|
/**
|
|
21
57
|
* Durable synchronization state for account sources on PostgreSQL. Every change to an owner's content runs under
|
|
22
58
|
* that owner's fence (`fence`), and a change made with a handle checks its lease, generation and host liveness in
|
|
23
|
-
* the same transaction. Applying a source purges its old copy and every derived result that depends on it
|
|
24
|
-
* replacement is written.
|
|
25
|
-
*
|
|
59
|
+
* the same transaction. Applying a changed source purges its old copy and every derived result that depends on it
|
|
60
|
+
* before the replacement is written. A read that repeats the text of a copy the store holds is never charged again,
|
|
61
|
+
* and with the host's `refresh` binding a read of that very copy keeps it in place. Provider reads, embedding and model
|
|
62
|
+
* calls stay outside the store. The constructor runs no DDL: the host's migration runs `sourceSyncSchema` and core's
|
|
63
|
+
* `sourceDerivationSchema` first.
|
|
26
64
|
*
|
|
27
65
|
* @typeParam Prepared - The host's in-memory value for one document, passed through to its `write` binding.
|
|
28
66
|
*/
|
|
@@ -437,7 +475,7 @@ export class SqlAccountSourceSyncStore {
|
|
|
437
475
|
generation: h.lease.generation, messageIds: [...ids], version: version, expiresAt: doc.cache.expiresAt };
|
|
438
476
|
createSourceDerivation([dependency]);
|
|
439
477
|
return { dependency, messages: doc.messages.map((m) => ({ ...m })),
|
|
440
|
-
activityAt: doc.activityAt, characters: text.length, contentHash: doc.contentHash };
|
|
478
|
+
activityAt: doc.activityAt, characters: text.length, contentHash: doc.contentHash, textHash: textHashOf(text) };
|
|
441
479
|
}
|
|
442
480
|
async documentRows(tx, scope) {
|
|
443
481
|
const rows = await tx.all(`SELECT document FROM ${this.t.documents}
|
|
@@ -457,7 +495,7 @@ export class SqlAccountSourceSyncStore {
|
|
|
457
495
|
const selected = [];
|
|
458
496
|
let held = 0;
|
|
459
497
|
for (const doc of [...rows].sort(newest)) {
|
|
460
|
-
if (doc
|
|
498
|
+
if (!retainable(doc, policy, now))
|
|
461
499
|
continue;
|
|
462
500
|
if (selected.length >= Math.min(policy.maxRetainedDocuments, maximumDocuments))
|
|
463
501
|
break;
|
|
@@ -470,14 +508,23 @@ export class SqlAccountSourceSyncStore {
|
|
|
470
508
|
}
|
|
471
509
|
/**
|
|
472
510
|
* Applies one read result for a work item taken in this cycle, under a live lease and the owner fence. A retry
|
|
473
|
-
* keeps the work unresolved.
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
511
|
+
* keeps the work unresolved. A document is weighed first: excluded, outside the retained limits or, unless it
|
|
512
|
+
* repeats the text of a copy the store still holds, over the indexing allowance, it is recorded as a skip with its
|
|
513
|
+
* reason. A document that fits replaces the source's old copy in the same transaction (host write, indexing charge
|
|
514
|
+
* and provenance), after the old copy and every result derived from it are purged, evicting this connection's older
|
|
515
|
+
* sources the limits no longer admit. A read that repeats the text and message spans of a copy the store still holds,
|
|
516
|
+
* within its expiry and window, adds nothing to index and is never charged. When it also repeats the copy's version
|
|
517
|
+
* and content hash, the host's `refresh` binding keeps the copy, with every result derived from it, and gives it the
|
|
518
|
+
* read's expiry and message windows; otherwise the copy is written again from a fresh preparation. A skip and every
|
|
519
|
+
* other result purge the old copy, except an `indexing_limit` skip that finds the copy still held, still admitted as
|
|
520
|
+
* it is by the retained limits and the read beginning with its text, every message of it at the same span: that
|
|
521
|
+
* copy, admitted and charged under an earlier read, stays with the shorter of its own and the read's expiry.
|
|
477
522
|
*
|
|
478
|
-
* @param prepared - The host's prepared value for a document result; null for any other result
|
|
479
|
-
*
|
|
480
|
-
*
|
|
523
|
+
* @param prepared - The host's prepared value for a document result; null for any other result, and for a document
|
|
524
|
+
* weighed before its preparation.
|
|
525
|
+
* @returns The disposition and the handle later calls use, whose ticket reflects any purge; `pending` with the same
|
|
526
|
+
* handle, and nothing recorded, for a document without its prepared value that the store has to write; null for a
|
|
527
|
+
* stale handle or work item.
|
|
481
528
|
* @throws {SourceSyncFault} `invalid_response` for a result that does not match its work item or is malformed.
|
|
482
529
|
* @throws {Error} `invalid_source_allowance` when the host's allowance is not made of nonnegative safe integers.
|
|
483
530
|
*/
|
|
@@ -505,46 +552,92 @@ export class SqlAccountSourceSyncStore {
|
|
|
505
552
|
: result.kind === 'skipped' ? { kind: 'skipped', reason: result.reason }
|
|
506
553
|
: { kind: 'pending' };
|
|
507
554
|
let victims = [];
|
|
555
|
+
// The old copy stays when the read is that copy and the host refreshes it in place (`refreshed`), or when an
|
|
556
|
+
// indexing skip keeps it (`kept`, with `shortened` its provenance); a read that repeats its text is written again
|
|
557
|
+
// without a charge (`charge`).
|
|
558
|
+
let refreshed = false;
|
|
559
|
+
let kept = false;
|
|
560
|
+
let charge = true;
|
|
561
|
+
let shortened = null;
|
|
508
562
|
const excluded = await tx.get(`SELECT 1 FROM ${this.t.exclusions}
|
|
509
563
|
WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=$4`, [...params(s), work.sourceId]);
|
|
510
564
|
if (result.kind === 'document' && excluded)
|
|
511
565
|
disposition = { kind: 'skipped', reason: 'excluded' };
|
|
512
566
|
else if (result.kind === 'document') {
|
|
513
|
-
|
|
567
|
+
const read = this.normalized(h, work, result.document, row.policy);
|
|
514
568
|
const allowance = await this.options.bindings.allowance(tx, s);
|
|
515
569
|
if (!Object.values(allowance).every(validNumber))
|
|
516
570
|
throw new Error('invalid_source_allowance');
|
|
517
571
|
const maximumDocuments = allowance.maximumConnectionDocuments;
|
|
518
572
|
const maximumCharacters = allowance.maximumConnectionHeldCharacters;
|
|
519
|
-
const
|
|
520
|
-
|
|
573
|
+
const now = this.now();
|
|
574
|
+
const selected = this.choose([...all.filter((d) => d !== old), read], row.policy, maximumDocuments, maximumCharacters, now);
|
|
575
|
+
// A copy still within its expiry and window was charged when it was admitted: a read that repeats its text
|
|
576
|
+
// adds nothing to index, and one that also repeats its version and content hash is that copy. A copy past its
|
|
577
|
+
// expiry or window is no longer held, whether or not `expire` has removed it yet.
|
|
578
|
+
const holding = old !== undefined && retainable(old, row.policy, now) ? old : undefined;
|
|
579
|
+
const repeats = holding !== undefined && sameText(holding, read);
|
|
580
|
+
if (!selected.includes(read)) {
|
|
521
581
|
const reason = selected.length >= Math.min(row.policy.maxRetainedDocuments, maximumDocuments)
|
|
522
582
|
? 'document_limit' : 'held_limit';
|
|
523
583
|
disposition = { kind: 'skipped', reason };
|
|
524
|
-
normalized = null;
|
|
525
584
|
}
|
|
526
|
-
else if (
|
|
585
|
+
else if (!repeats && read.characters > Math.min(row.policy.maxIndexedCharacters, allowance.additionalIndexedCharacters)) {
|
|
527
586
|
disposition = { kind: 'skipped', reason: 'indexing_limit' };
|
|
528
|
-
|
|
587
|
+
// The copy stays where the read begins with its text and the retained limits still admit it as it is, so an
|
|
588
|
+
// edit of a message it holds, or a lowered ceiling, removes it.
|
|
589
|
+
kept = holding !== undefined && holding.dependency.generation === read.dependency.generation
|
|
590
|
+
&& holding.textHash !== undefined
|
|
591
|
+
&& holding.textHash === textHashOf(result.document.read.content.text.slice(0, holding.characters))
|
|
592
|
+
&& holding.messages.every((message) => read.messages.some((span) => sameSpan(message, span)))
|
|
593
|
+
&& this.choose(all, row.policy, maximumDocuments, maximumCharacters, now).includes(holding);
|
|
594
|
+
if (kept && holding !== undefined) {
|
|
595
|
+
// The kept copy takes the shorter of its own and the read's expiry and message windows, never a longer one.
|
|
596
|
+
const windows = new Map(read.messages.map((m) => [m.messageId, m.windowExpiresAt]));
|
|
597
|
+
const messages = holding.messages.map((m) => ({ ...m,
|
|
598
|
+
windowExpiresAt: Math.min(m.windowExpiresAt, windows.get(m.messageId) ?? m.windowExpiresAt) }));
|
|
599
|
+
const expiresAt = Math.min(holding.dependency.expiresAt, read.dependency.expiresAt, ...messages.map((m) => m.windowExpiresAt));
|
|
600
|
+
shortened = { ...holding, messages, dependency: { ...holding.dependency, expiresAt } };
|
|
601
|
+
}
|
|
529
602
|
}
|
|
530
603
|
else {
|
|
531
|
-
|
|
532
|
-
|
|
604
|
+
refreshed = holding !== undefined && sameCopy(holding, read)
|
|
605
|
+
&& holding.dependency.generation === read.dependency.generation
|
|
606
|
+
&& typeof this.options.bindings.refresh === 'function';
|
|
607
|
+
if (!refreshed && prepared === null) {
|
|
608
|
+
const pending = { kind: 'pending' };
|
|
609
|
+
return { handle: h, disposition: pending };
|
|
610
|
+
}
|
|
611
|
+
normalized = read;
|
|
612
|
+
charge = !repeats;
|
|
533
613
|
victims = all.filter((d) => d !== old && !selected.includes(d)).map((d) => d.dependency.sourceId);
|
|
534
|
-
disposition = { kind: 'applied', version:
|
|
614
|
+
disposition = { kind: 'applied', version: read.dependency.version };
|
|
535
615
|
}
|
|
536
616
|
}
|
|
537
617
|
let ticket = h.ticket;
|
|
538
618
|
const localFence = this.fenceOn(tx);
|
|
539
|
-
|
|
619
|
+
const purged = refreshed || kept ? victims : [...victims, work.sourceId];
|
|
620
|
+
for (const sourceId of [...new Set(purged)].sort()) {
|
|
540
621
|
const invalidated = await localFence.invalidate(s, { kind: 'source', connectionId: s.connectionId, sourceId }, async () => { }, { expected: ticket });
|
|
541
622
|
if (invalidated.kind !== 'applied')
|
|
542
623
|
throw new StaleSync();
|
|
543
624
|
ticket = invalidated.ticket;
|
|
544
625
|
}
|
|
545
|
-
if (
|
|
626
|
+
if (shortened) {
|
|
627
|
+
await tx.run(`UPDATE ${this.t.documents} SET document=$5::jsonb,expires_at=$6
|
|
628
|
+
WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=$4`, [...params(s), work.sourceId,
|
|
629
|
+
JSON.stringify(shortened), shortened.dependency.expiresAt]);
|
|
630
|
+
}
|
|
631
|
+
if (normalized && result.kind === 'document' && refreshed) {
|
|
632
|
+
await this.options.bindings.refresh?.(tx, s, normalized.dependency, result.document);
|
|
633
|
+
await tx.run(`UPDATE ${this.t.documents} SET document=$5::jsonb,expires_at=$6
|
|
634
|
+
WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=$4`, [...params(s), work.sourceId,
|
|
635
|
+
JSON.stringify(normalized), normalized.dependency.expiresAt]);
|
|
636
|
+
}
|
|
637
|
+
else if (normalized && result.kind === 'document' && prepared !== null) {
|
|
546
638
|
await this.options.bindings.write(tx, s, normalized.dependency, result.document, prepared);
|
|
547
|
-
|
|
639
|
+
if (charge)
|
|
640
|
+
await this.options.bindings.chargeIndexed(tx, s, normalized.characters);
|
|
548
641
|
await tx.run(`INSERT INTO ${this.t.documents}(tenant_id,owner_id,connection_id,source_id,document,expires_at)
|
|
549
642
|
VALUES($1,$2,$3,$4,$5::jsonb,$6)`, [...params(s), work.sourceId,
|
|
550
643
|
JSON.stringify(normalized), normalized.dependency.expiresAt]);
|
|
@@ -940,12 +1033,15 @@ export class SqlAccountSourceSyncStore {
|
|
|
940
1033
|
}
|
|
941
1034
|
/**
|
|
942
1035
|
* Removes retained sources whose expiry has passed, with every result derived from them, also while the connection
|
|
943
|
-
* is paused, then expires the owner's derived results.
|
|
944
|
-
* reads refuse an expired source regardless.
|
|
1036
|
+
* is paused or the owner's fence is inactive, then expires the owner's derived results. An inactive owner, such as a
|
|
1037
|
+
* suspended account or one a control's barrier holds, stays inactive; reads refuse an expired source regardless.
|
|
1038
|
+
* Each source goes in a transaction of its own, so a later failure leaves the removals before it in place.
|
|
945
1039
|
*
|
|
946
1040
|
* @param limit - At most this many sources, an integer from 1 to 1,000.
|
|
947
1041
|
* @returns How many sources were removed.
|
|
948
|
-
* @throws {Error} `invalid_sync_batch` for another limit
|
|
1042
|
+
* @throws {Error} `invalid_sync_batch` for another limit, and the failure itself when a removal or the results'
|
|
1043
|
+
* expiry fails: once a source was removed, a `SourceSyncExpiryError` with the failure's message, the failure as its
|
|
1044
|
+
* `cause` and the removals made before it in `partial`.
|
|
949
1045
|
*/
|
|
950
1046
|
async expire(scope, limit = 100) {
|
|
951
1047
|
if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
|
|
@@ -954,29 +1050,145 @@ export class SqlAccountSourceSyncStore {
|
|
|
954
1050
|
WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND expires_at <= $4
|
|
955
1051
|
ORDER BY expires_at,source_id LIMIT $5`, [...params(scope), this.now(), limit]);
|
|
956
1052
|
let removed = 0;
|
|
957
|
-
|
|
958
|
-
const
|
|
959
|
-
|
|
1053
|
+
try {
|
|
1054
|
+
for (const row of rows)
|
|
1055
|
+
if (await this.expireSource(scope, row.source_id))
|
|
1056
|
+
removed++;
|
|
1057
|
+
await this.fence.expireResults(scope, limit);
|
|
1058
|
+
}
|
|
1059
|
+
catch (error) {
|
|
1060
|
+
// The removals before the failure have committed, so their count goes to the caller with it.
|
|
1061
|
+
throw removed > 0 ? new SourceSyncExpiryError(removed, error) : error;
|
|
1062
|
+
}
|
|
1063
|
+
return removed;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Removes one retained source whose expiry has passed, with every result derived from it, under its owner's row
|
|
1067
|
+
* lock, whether or not the owner's fence is active: the fence's invalidation advances the owner's epoch, so no handle
|
|
1068
|
+
* from before it applies anything, and leaves an inactive owner inactive.
|
|
1069
|
+
*
|
|
1070
|
+
* @returns Whether it was removed: false when it is gone or its expiry has not passed, as after a later read moved it.
|
|
1071
|
+
*/
|
|
1072
|
+
async expireSource(scope, sourceId) {
|
|
1073
|
+
return this.db.transaction(async (tx) => {
|
|
1074
|
+
const fence = this.fenceOn(tx);
|
|
1075
|
+
// The owner fence's row lock first, as every store transaction takes it; an answer of false changes nothing.
|
|
1076
|
+
await fence.activateOwner(scope, async () => false);
|
|
1077
|
+
const current = await tx.get(`SELECT expires_at FROM ${this.t.documents}
|
|
1078
|
+
WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=$4`, [...params(scope), sourceId]);
|
|
1079
|
+
if (!current || Number(current.expires_at) > this.now())
|
|
1080
|
+
return false;
|
|
1081
|
+
await fence.invalidate(scope, { kind: 'source', connectionId: scope.connectionId, sourceId }, async () => { });
|
|
1082
|
+
return true;
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Removes retained sources whose expiry has passed from every connection, or from `owner`'s alone, the earliest
|
|
1087
|
+
* expiry first, each with every result derived from it as `expire` does for one connection, however far a
|
|
1088
|
+
* maintenance walk is from them and whether or not their owner's fence is active; then expires the derived results
|
|
1089
|
+
* of each owner it removed a source of. The store's own expiry decides, which a policy change's prune and an
|
|
1090
|
+
* indexing skip that keeps a copy may have shortened. An owner whose removal fails, as when its host purge keeps
|
|
1091
|
+
* failing, is counted and left out of the call's later reads, so a call with up to `limit` such owners never holds
|
|
1092
|
+
* back another owner's copies. An owner whose derived results then fail to expire is counted too, once; the call
|
|
1093
|
+
* still answers every source it removed, and the other owners' results still expire.
|
|
1094
|
+
*
|
|
1095
|
+
* @param limit - At most this many sources, an integer from 1 to 1,000.
|
|
1096
|
+
* @param owner - The owner whose sources alone are swept, or null for every owner.
|
|
1097
|
+
* @returns The sources removed, and the owners whose removal or whose derived results' expiry failed.
|
|
1098
|
+
* @throws {Error} `invalid_sync_batch` for another limit.
|
|
1099
|
+
*/
|
|
1100
|
+
async sweepExpired(limit = 100, owner = null) {
|
|
1101
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
|
|
1102
|
+
throw new Error('invalid_sync_batch');
|
|
1103
|
+
let removed = 0;
|
|
1104
|
+
let failed = 0;
|
|
1105
|
+
const swept = new Map();
|
|
1106
|
+
// The owners whose removal failed, which each later read leaves out.
|
|
1107
|
+
const passed = new Set();
|
|
1108
|
+
const passedTenants = [];
|
|
1109
|
+
const passedOwners = [];
|
|
1110
|
+
// Each read after the first follows a newly failed owner, and the call ends once more than `limit` owners have
|
|
1111
|
+
// failed, so at most `limit` + 2 reads run.
|
|
1112
|
+
for (;;) {
|
|
1113
|
+
const rows = await this.db.all(`SELECT d.tenant_id,d.owner_id,d.connection_id,d.source_id FROM ${this.t.documents} d
|
|
1114
|
+
WHERE d.expires_at <= $1 AND ($2::text IS NULL OR (d.tenant_id=$2 AND d.owner_id=$3))
|
|
1115
|
+
AND NOT EXISTS(SELECT 1 FROM unnest($5::text[],$6::text[]) AS p(tenant_id,owner_id)
|
|
1116
|
+
WHERE p.tenant_id=d.tenant_id AND p.owner_id=d.owner_id)
|
|
1117
|
+
ORDER BY d.expires_at,d.tenant_id,d.owner_id,d.connection_id,d.source_id LIMIT $4`, [this.now(), owner?.tenantId ?? null, owner?.ownerId ?? null, limit - removed, passedTenants, passedOwners]);
|
|
1118
|
+
let passedNow = false;
|
|
1119
|
+
for (const row of rows) {
|
|
1120
|
+
const ownerKey = JSON.stringify([row.tenant_id, row.owner_id]);
|
|
1121
|
+
if (passed.has(ownerKey))
|
|
1122
|
+
continue;
|
|
1123
|
+
try {
|
|
1124
|
+
if (await this.expireSource({ tenantId: row.tenant_id, ownerId: row.owner_id, connectionId: row.connection_id }, row.source_id)) {
|
|
1125
|
+
removed++;
|
|
1126
|
+
swept.set(ownerKey, { tenantId: row.tenant_id, ownerId: row.owner_id });
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
catch {
|
|
1130
|
+
failed++;
|
|
1131
|
+
passed.add(ownerKey);
|
|
1132
|
+
passedTenants.push(row.tenant_id);
|
|
1133
|
+
passedOwners.push(row.owner_id);
|
|
1134
|
+
passedNow = true;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
if (!passedNow || removed >= limit || passed.size > limit)
|
|
960
1138
|
break;
|
|
1139
|
+
}
|
|
1140
|
+
// An owner whose derived results cannot be expired counts once, as a failed removal does; the copies already
|
|
1141
|
+
// removed stay counted, and every other owner's results still expire.
|
|
1142
|
+
for (const [ownerKey, sweptOwner] of swept) {
|
|
961
1143
|
try {
|
|
962
|
-
|
|
963
|
-
const current = await tx.get(`SELECT expires_at FROM ${this.t.documents}
|
|
964
|
-
WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=$4`, [...params(scope), row.source_id]);
|
|
965
|
-
if (!current || Number(current.expires_at) > this.now())
|
|
966
|
-
return false;
|
|
967
|
-
await this.fenceOn(tx).invalidate(scope, { kind: 'source', connectionId: scope.connectionId, sourceId: row.source_id }, async () => { }, { expected: ticket });
|
|
968
|
-
return true;
|
|
969
|
-
});
|
|
970
|
-
if (result.kind === 'applied' && result.value)
|
|
971
|
-
removed++;
|
|
1144
|
+
await this.fence.expireResults(sweptOwner, limit);
|
|
972
1145
|
}
|
|
973
|
-
catch
|
|
974
|
-
if (!(
|
|
975
|
-
|
|
1146
|
+
catch {
|
|
1147
|
+
if (!passed.has(ownerKey))
|
|
1148
|
+
failed++;
|
|
976
1149
|
}
|
|
977
1150
|
}
|
|
978
|
-
|
|
979
|
-
|
|
1151
|
+
return { removed, failed };
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Removes a stopped owner's rows from the store in one transaction: every connection of the owner, with its work
|
|
1155
|
+
* rows, retained-source provenance and exclusions, once each connection is disconnected with no barrier, lease, work
|
|
1156
|
+
* or retained source. `remove` runs first in the same transaction, after the owner fence's row lock and the
|
|
1157
|
+
* connections' row locks, for a lifecycle's own rows; `SourceRevocations.removeOwner` passes its own. The core
|
|
1158
|
+
* fence's owner row and its deletion records stay: they hold no content, keep the owner's epoch ahead of any old
|
|
1159
|
+
* handle, and a restore replays the deletion from them.
|
|
1160
|
+
*
|
|
1161
|
+
* @param remove - The lifecycle's removal in the same transaction; answering false rolls the whole call back.
|
|
1162
|
+
* @returns Whether the owner's rows were removed: false, changing nothing, while a connection of the owner has not
|
|
1163
|
+
* stopped or when `remove` answers false.
|
|
1164
|
+
*/
|
|
1165
|
+
async removeOwner(owner, remove = async () => true) {
|
|
1166
|
+
const where = [owner.tenantId, owner.ownerId];
|
|
1167
|
+
try {
|
|
1168
|
+
await this.db.transaction(async (tx) => {
|
|
1169
|
+
// The owner fence's row lock first, as every store transaction takes it; an answer of false changes nothing.
|
|
1170
|
+
await this.fenceOn(tx).activateOwner(owner, async () => false);
|
|
1171
|
+
const rows = await tx.all(`SELECT * FROM ${this.t.connections}
|
|
1172
|
+
WHERE tenant_id=$1 AND owner_id=$2 ORDER BY connection_id FOR UPDATE`, where);
|
|
1173
|
+
if (rows.some((row) => row.barrier !== null || row.lease_token !== null
|
|
1174
|
+
|| row.state.status !== 'disconnected'))
|
|
1175
|
+
throw new StaleSync();
|
|
1176
|
+
if (await tx.get(`SELECT 1 FROM ${this.t.documents} WHERE tenant_id=$1 AND owner_id=$2 LIMIT 1`, where)
|
|
1177
|
+
|| await tx.get(`SELECT 1 FROM ${this.t.work} WHERE tenant_id=$1 AND owner_id=$2 LIMIT 1`, where)) {
|
|
1178
|
+
throw new StaleSync();
|
|
1179
|
+
}
|
|
1180
|
+
if (!await remove(tx))
|
|
1181
|
+
throw new StaleSync();
|
|
1182
|
+
// The work rows, provenance and exclusions go with their connection (ON DELETE CASCADE).
|
|
1183
|
+
await tx.run(`DELETE FROM ${this.t.connections} WHERE tenant_id=$1 AND owner_id=$2`, where);
|
|
1184
|
+
});
|
|
1185
|
+
return true;
|
|
1186
|
+
}
|
|
1187
|
+
catch (error) {
|
|
1188
|
+
if (error instanceof StaleSync)
|
|
1189
|
+
return false;
|
|
1190
|
+
throw error;
|
|
1191
|
+
}
|
|
980
1192
|
}
|
|
981
1193
|
}
|
|
982
1194
|
//# sourceMappingURL=SqlAccountSourceSyncStore.js.map
|