@framers/agentos-ext-account-sources 0.10.0 → 0.11.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.
@@ -142,6 +142,21 @@ const addedByMessage = (stored, read) => {
142
142
  */
143
143
  const sameCopy = (stored, read) => sameText(stored, read) && stored.dependency.version === read.dependency.version
144
144
  && stored.contentHash === read.contentHash;
145
+ /**
146
+ * The host's allowance, checked: its three amounts nonnegative safe integers, and its period's end one too when given.
147
+ *
148
+ * @throws {Error} `invalid_source_allowance` for any other value.
149
+ */
150
+ const checkedAllowance = (allowance) => {
151
+ if (![allowance.maximumConnectionDocuments, allowance.maximumConnectionHeldCharacters,
152
+ allowance.additionalIndexedCharacters].every(validNumber)
153
+ || (allowance.indexingPeriodEndsAt !== undefined && !validNumber(allowance.indexingPeriodEndsAt))) {
154
+ throw new Error('invalid_source_allowance');
155
+ }
156
+ return allowance;
157
+ };
158
+ /** The end of the indexing period the allowance names, or null when it names none or the period has ended by `now`. */
159
+ const periodOf = (allowance, now) => allowance.indexingPeriodEndsAt !== undefined && allowance.indexingPeriodEndsAt > now ? allowance.indexingPeriodEndsAt : null;
145
160
  /**
146
161
  * Durable synchronization state for account sources on PostgreSQL. Every change to an owner's content runs under
147
162
  * that owner's fence (`fence`), and a change made with a handle checks its lease, generation and host liveness in
@@ -150,8 +165,11 @@ const sameCopy = (stored, read) => sameText(stored, read) && stored.dependency.v
150
165
  * before purging it, so a purge that fails leaves nothing derived from it deliverable. A read that repeats the text of
151
166
  * a copy the store holds is never charged again, one that changes it is charged, by the hash the store keeps of each
152
167
  * message's text, its new and changed messages and the text beside them, and with the host's `refresh` binding a read
153
- * of that very copy keeps it in place. Provider reads, embedding and model calls stay outside the store. The
154
- * constructor runs no DDL: the host's migration runs `sourceSyncSchema` and core's `sourceDerivationSchema` first.
168
+ * of that very copy keeps it in place. A host whose allowance names the end of its indexing period has each source's
169
+ * indexed text weighed for the rest of the period too, past its copy's expiry, and a source the allowance could not
170
+ * take settled with no read until it can, for a reconcile interval at a time. Provider reads, embedding and model calls
171
+ * stay outside the store. The constructor runs no DDL: the host's migration runs `sourceSyncSchema` and core's
172
+ * `sourceDerivationSchema` first.
155
173
  *
156
174
  * @typeParam Prepared - The host's in-memory value for one document, passed through to its `write` binding.
157
175
  */
@@ -186,6 +204,88 @@ export class SqlAccountSourceSyncStore {
186
204
  throw new Error('invalid_source_sync_clock');
187
205
  return n;
188
206
  }
207
+ /** Whether the charges table was found in place; a table found stays found, one missing is looked for again. */
208
+ chargesTable = false;
209
+ /**
210
+ * Whether the charges table of `sourceSyncSchema` is in place, which a database migrated by an earlier release's
211
+ * statements lacks: without it the store records no charge and no deferral.
212
+ */
213
+ async chargesHeld(tx) {
214
+ if (!this.chargesTable) {
215
+ const found = await tx.get(`SELECT to_regclass($1::text) IS NOT NULL AS present`, [this.t.charges]);
216
+ this.chargesTable = found?.present === true;
217
+ }
218
+ return this.chargesTable;
219
+ }
220
+ /** The source's charge record in the period ending at `period`, or null when it has none there. */
221
+ async chargeRecord(tx, scope, sourceId, period) {
222
+ return tx.get(`SELECT period_ends_at,charged,deferred FROM ${this.t.charges}
223
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=$4 AND period_ends_at=$5`, [...params(scope), sourceId, period]);
224
+ }
225
+ /** Writes the source's charge record for the period ending at `period`, in place of any earlier one. */
226
+ async recordCharge(tx, scope, sourceId, period, charged, deferred) {
227
+ await tx.run(`INSERT INTO ${this.t.charges}(tenant_id,owner_id,connection_id,source_id,period_ends_at,charged,deferred)
228
+ VALUES($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb) ON CONFLICT(tenant_id,owner_id,connection_id,source_id)
229
+ DO UPDATE SET period_ends_at=excluded.period_ends_at,charged=excluded.charged,deferred=excluded.deferred`, [...params(scope), sourceId, period, charged === null ? null : JSON.stringify(charged),
230
+ deferred === null ? null : JSON.stringify(deferred)]);
231
+ }
232
+ /** Removes the source's charge record, when the table is in place. */
233
+ async forgetCharge(tx, scope, sourceId) {
234
+ if (!await this.chargesHeld(tx))
235
+ return;
236
+ await tx.run(`DELETE FROM ${this.t.charges} WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3
237
+ AND source_id=$4`, [...params(scope), sourceId]);
238
+ }
239
+ /** Whether the source has a charge record, in any period, when the table is in place. */
240
+ async recorded(scope, sourceId) {
241
+ if (!await this.chargesHeld(this.db))
242
+ return false;
243
+ return !!await this.db.get(`SELECT 1 FROM ${this.t.charges} WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3
244
+ AND source_id=$4`, [...params(scope), sourceId]);
245
+ }
246
+ /**
247
+ * Removes up to `limit` charge records whose period has ended by the store's clock, the earliest first: every
248
+ * owner's, or `owner`'s alone, or only those of `connectionId` of that owner. Answers how many went.
249
+ */
250
+ async forgetEnded(owner, connectionId, limit) {
251
+ if (!await this.chargesHeld(this.db))
252
+ return 0;
253
+ const ended = `period_ends_at<=$1 AND ($2::text IS NULL OR (tenant_id=$2 AND owner_id=$3))
254
+ AND ($4::text IS NULL OR connection_id=$4)`;
255
+ // The period's end is checked on the row itself as well, so a record a read moved to a later period since the
256
+ // look stays.
257
+ const removed = await this.db.run(`DELETE FROM ${this.t.charges} WHERE ${ended}
258
+ AND (tenant_id,owner_id,connection_id,source_id) IN (SELECT tenant_id,owner_id,connection_id,source_id
259
+ FROM ${this.t.charges} WHERE ${ended} ORDER BY period_ends_at,tenant_id,owner_id,connection_id,source_id LIMIT $5)`, [this.now(), owner?.tenantId ?? null, owner?.ownerId ?? null, connectionId, limit]);
260
+ return removed.changes;
261
+ }
262
+ /**
263
+ * The listed sources `page` settles as `indexing_limit` skips with no read: each holds a deferral of the host's
264
+ * current indexing period at the connection's generation, recorded less than the policy's `reconcileMs` ago, the store
265
+ * holds no copy of it within its expiry, and the room the allowance leaves is no larger than the room it was skipped
266
+ * with. The allowance is read only when a listed source holds such a deferral. Past the reconcile interval the source
267
+ * is read again, so a change at the source, a deletion or an edit that makes it fit, reaches the store, as a
268
+ * reconcile reads every listed source again.
269
+ */
270
+ async deferredOf(tx, scope, policy, generation, ids) {
271
+ if (ids.length === 0 || !await this.chargesHeld(tx))
272
+ return new Set();
273
+ const now = this.now();
274
+ const marks = await tx.all(`SELECT c.source_id,c.period_ends_at,c.deferred FROM ${this.t.charges} c
275
+ WHERE c.tenant_id=$1 AND c.owner_id=$2 AND c.connection_id=$3 AND c.source_id=ANY($4::text[])
276
+ AND c.deferred IS NOT NULL AND c.period_ends_at>$5
277
+ AND NOT EXISTS(SELECT 1 FROM ${this.t.documents} d WHERE d.tenant_id=c.tenant_id AND d.owner_id=c.owner_id
278
+ AND d.connection_id=c.connection_id AND d.source_id=c.source_id AND d.expires_at>$5)`, [...params(scope), [...ids], now]);
279
+ const current = marks.filter((mark) => mark.deferred.generation === generation && validNumber(mark.deferred.limit)
280
+ && validNumber(mark.deferred.at) && now < mark.deferred.at + policy.reconcileMs);
281
+ if (current.length === 0)
282
+ return new Set();
283
+ const allowance = checkedAllowance(await this.options.bindings.allowance(tx, scope));
284
+ const period = periodOf(allowance, now);
285
+ const room = Math.min(policy.maxIndexedCharacters, allowance.additionalIndexedCharacters);
286
+ return new Set(current.filter((mark) => period !== null && Number(mark.period_ends_at) === period
287
+ && room <= mark.deferred.limit).map((mark) => mark.source_id));
288
+ }
189
289
  fenceOn(db) {
190
290
  return new SqlSourceDerivationStore(db, {
191
291
  tablePrefix: this.options.derivationPrefix, clock: this.clock,
@@ -386,8 +486,17 @@ export class SqlAccountSourceSyncStore {
386
486
  * counts they are weighed against, so a read given up before is given up again at its next failure; a read the
387
487
  * provider answered starts a count of its own.
388
488
  *
489
+ * A source an `indexing_limit` skip deferred in the host's current indexing period is queued settled, as the same
490
+ * skip, with no read, no purge and no deletion record, while the connection keeps the generation of the skip, less
491
+ * than the policy's `reconcileMs` has passed since it, the store holds no copy of the source and the allowance leaves
492
+ * no more room than the skip had: the host's `allowance` binding is read for that, only when a listed source holds
493
+ * such a deferral. The skip counts among the cycle's settled and skipped items, and not as a provider's answer. Once
494
+ * the period turns, the room grows, the generation moves or the reconcile interval has passed, the source is queued
495
+ * pending and read again, so a change at the source reaches the store within about one reconcile interval.
496
+ *
389
497
  * @throws {SourceSyncFault} `invalid_response` for an id longer than 512 characters, and the page rules'
390
498
  * `invalid_source_sync_page` for a page the checkpoint does not admit.
499
+ * @throws {Error} `invalid_source_allowance` when the allowance read for a deferral is not valid.
391
500
  */
392
501
  async page(h, expected, page) {
393
502
  return this.withHandle(h, async (tx, row) => {
@@ -420,19 +529,25 @@ export class SqlAccountSourceSyncStore {
420
529
  earlier.set(work.sourceId, work);
421
530
  }
422
531
  }
532
+ // The sources the allowance deferred, settled here as their skip, so the run reads none of them.
533
+ const deferred = await this.deferredOf(tx, s, row.policy, cp.generation, ids);
423
534
  for (const id of ids) {
424
535
  const before = earlier.get(id);
425
536
  if (tally && before && isTerminalSourceWork(before.disposition)) {
426
537
  tally = { ...tally, settled: tally.settled - 1,
427
538
  skipped: tally.skipped - (before.disposition.kind === 'skipped' ? 1 : 0) };
428
539
  }
540
+ const settled = deferred.has(id);
429
541
  const failing = before !== undefined && !answers(before.disposition) ? before : undefined;
430
542
  const work = { cycleId: cp.cycleId, generation: cp.generation, sourceId: id,
431
- discoveredRevision: next.checkpoint.revision, attempts: 0, disposition: { kind: 'pending' },
543
+ discoveredRevision: next.checkpoint.revision, attempts: 0,
544
+ disposition: settled ? { kind: 'skipped', reason: 'indexing_limit' } : { kind: 'pending' },
432
545
  ...(failing?.failures !== undefined ? { failures: failing.failures } : {}),
433
546
  ...(failing?.answeredAtFailure !== undefined ? { answeredAtFailure: failing.answeredAtFailure } : {}),
434
547
  ...(failing?.preparedAtFailure !== undefined ? { preparedAtFailure: failing.preparedAtFailure } : {}) };
435
548
  await this.putWork(tx, s, work);
549
+ if (tally && settled)
550
+ tally = { ...tally, settled: tally.settled + 1, skipped: tally.skipped + 1 };
436
551
  }
437
552
  // Counts that would fall below zero were not the store's: count them again from the rows just written.
438
553
  if (!validTally(tally))
@@ -726,6 +841,16 @@ export class SqlAccountSourceSyncStore {
726
841
  * does not begin with the copy's text, so with the allowance spent it removes the thread's copy, and the thread stays
727
842
  * out of the store until a read of it fits the allowance whole.
728
843
  *
844
+ * An allowance that names the end of the host's indexing period (`indexingPeriodEndsAt`), with the charges table in
845
+ * place, has the read weighed against the newest text the store wrote or refreshed for the source in that period as
846
+ * well, by the same rules, and charged the smaller amount: a source read again after its copy expired and was
847
+ * removed is charged nothing when its text is unchanged and only what it adds otherwise, as a reply's separator and
848
+ * text. Each written or refreshed read becomes the period's text for the source. An `indexing_limit` skip that keeps
849
+ * no copy records a deferral with the room the allowance left it and the time, which `page` honours for a reconcile
850
+ * interval, and the record then keeps no provenance: nothing in it confirms the text of a source the store holds no
851
+ * copy of. A `gone` result and a skip the provider answers remove the source's record, before the application and
852
+ * with the withdrawal below, so a host purge that throws cannot keep it; the store's own skips keep it.
853
+ *
729
854
  * A `gone` result and a skip the provider answers withdraw the old copy before that, in a transaction of their own
730
855
  * (`withdraw`), and so does an `expired` skip, before its application runs again: the copy's expiry moves to the
731
856
  * store's clock, so from then on `readCurrent` answers it expired and the fence neither commits nor delivers a result
@@ -741,9 +866,10 @@ export class SqlAccountSourceSyncStore {
741
866
  * stale handle or work item.
742
867
  * @throws {SourceSyncFault} `invalid_response` for a result that does not match its work item or is malformed, a skip
743
868
  * that carries `attempt_limit` among them.
744
- * @throws {Error} `invalid_source_allowance` when the host's allowance is not made of nonnegative safe integers, and
745
- * `invalid_source_sync_charge` should the characters a read adds come out below zero or fractional, which the
746
- * ordered spans of a valid read and an intact copy rule out; nothing is charged or written.
869
+ * @throws {Error} `invalid_source_allowance` when the host's allowance is not made of nonnegative safe integers, its
870
+ * period's end included when given, and `invalid_source_sync_charge` should the characters a read adds come out
871
+ * below zero or fractional, which the ordered spans of a valid read and an intact copy rule out; nothing is charged
872
+ * or written.
747
873
  */
748
874
  async applyResult(h, work, result, prepared) {
749
875
  // The purge below runs in the transaction that records the result, so a binding that throws rolls the copy's
@@ -751,9 +877,11 @@ export class SqlAccountSourceSyncStore {
751
877
  // connection's copies, so a copy not held at this read cannot appear before the purge. A result that carries
752
878
  // `attempt_limit`, the store's own reason, withdraws nothing and is refused below.
753
879
  // The read result's type leaves `attempt_limit` out; a provider that breaks the type at run time still withdraws
754
- // nothing, so the comparison is made on the reason as the store's wider type.
880
+ // nothing, so the comparison is made on the reason as the store's wider type. The source's charge record goes in
881
+ // the withdrawal's transaction too, which runs for it even with no copy held, so the purge's rollback keeps none.
755
882
  if ((result.kind === 'gone' || (result.kind === 'skipped' && result.reason !== 'attempt_limit'))
756
- && await this.holds(h.lease.scope, work.sourceId) && await this.withdraw(h, work, result) === null)
883
+ && (await this.holds(h.lease.scope, work.sourceId) || await this.recorded(h.lease.scope, work.sourceId))
884
+ && await this.withdraw(h, work, result, true) === null)
757
885
  return null;
758
886
  // `withdrawn` once the copy of a read the weighing below finds expired has been withdrawn (`WithdrawFirst`).
759
887
  const apply = (withdrawn) => this.withHandle(h, async (tx, row) => {
@@ -814,34 +942,44 @@ export class SqlAccountSourceSyncStore {
814
942
  let kept = false;
815
943
  let charge = 0;
816
944
  let shortened = null;
945
+ // With the host's indexing period named and the charges table in place: the period's end (`period`), the
946
+ // source's record in it (`record`), and the deferral an indexing skip that keeps no copy records (`deferral`).
947
+ let period = null;
948
+ let record = null;
949
+ let deferral = null;
817
950
  const excluded = await tx.get(`SELECT 1 FROM ${this.t.exclusions}
818
951
  WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=$4`, [...params(s), work.sourceId]);
819
952
  if (result.kind === 'document' && excluded)
820
953
  disposition = { kind: 'skipped', reason: 'excluded' };
821
954
  else if (result.kind === 'document') {
822
955
  const read = this.normalized(h, work, result.document, row.policy);
823
- const allowance = await this.options.bindings.allowance(tx, s);
824
- if (!Object.values(allowance).every(validNumber))
825
- throw new Error('invalid_source_allowance');
956
+ const allowance = checkedAllowance(await this.options.bindings.allowance(tx, s));
826
957
  const maximumDocuments = allowance.maximumConnectionDocuments;
827
958
  const maximumCharacters = allowance.maximumConnectionHeldCharacters;
828
959
  const now = this.now();
960
+ period = periodOf(allowance, now);
961
+ if (period !== null && !await this.chargesHeld(tx))
962
+ period = null;
963
+ record = period === null ? null : await this.chargeRecord(tx, s, work.sourceId, period);
829
964
  const selected = this.choose([...all.filter((d) => d !== old), read], row.policy, maximumDocuments, maximumCharacters, now);
830
- // A copy still within its expiry and window was charged when it was admitted: a read that repeats its text
831
- // adds nothing to index, and one that also repeats its version and content hash is that copy. Any other read
832
- // adds the messages the copy does not hold unchanged, with the text beside them, or, against a copy whose
833
- // messages keep no text hash, what follows the copy's text when the read begins with it. A copy past its
834
- // expiry or window is no longer held, whether or not `expire` has removed it yet.
965
+ // A copy within its expiry and window was charged when it was admitted, and the newest text the store
966
+ // wrote or refreshed for the source in the host's current period was charged by then: a read that repeats
967
+ // either text adds nothing to index, and one that also repeats the copy's version and content hash is that
968
+ // copy. Any other read adds the messages the copy or the period's text does not hold unchanged, with the text
969
+ // beside them, or, against a copy whose messages keep no text hash, what follows the copy's text when the read
970
+ // begins with it; the smaller amount is the one charged. A copy past its expiry or window is not held,
971
+ // whether or not `expire` has removed it yet, while the period's text is weighed until the period ends.
835
972
  const holding = old !== undefined && retainable(old, row.policy, now) ? old : undefined;
836
- const repeats = holding !== undefined && sameText(holding, read);
837
973
  const text = result.document.read.content.text;
838
- const indexed = repeats ? 0 : holding === undefined ? read.characters
839
- : addedByMessage(holding, read)
840
- ?? (beginsWith(holding, read, text) ? read.characters - holding.characters : read.characters);
974
+ const weigh = (base) => !base ? read.characters
975
+ : sameText(base, read) ? 0
976
+ : addedByMessage(base, read) ?? (beginsWith(base, read, text) ? read.characters - base.characters : read.characters);
977
+ const indexed = Math.min(weigh(holding), weigh(record?.charged));
841
978
  // Each amount is the read's characters less parts of its text that do not overlap, so it is never negative;
842
979
  // a negative or fractional one would mean a malformed copy, and is refused rather than charged.
843
980
  if (!validNumber(indexed))
844
981
  throw new Error('invalid_source_sync_charge');
982
+ const room = Math.min(row.policy.maxIndexedCharacters, allowance.additionalIndexedCharacters);
845
983
  // A read whose cache lifetime has ended by the time it is weighed is an ordinary expired result, never a
846
984
  // malformed one: a document admitted with seconds of its lifetime left and prepared for longer comes back past
847
985
  // it, and refusing it as `invalid_response` would end the run with `sync_failed`, which clears the schedule.
@@ -858,7 +996,7 @@ export class SqlAccountSourceSyncStore {
858
996
  ? 'document_limit' : 'held_limit';
859
997
  disposition = { kind: 'skipped', reason };
860
998
  }
861
- else if (indexed > Math.min(row.policy.maxIndexedCharacters, allowance.additionalIndexedCharacters)) {
999
+ else if (indexed > room) {
862
1000
  disposition = { kind: 'skipped', reason: 'indexing_limit' };
863
1001
  // The copy stays where the read begins with its text and the retained limits still admit it as it is, so an
864
1002
  // edit of a message it holds, a reply that cuts its oldest message at the cap, or a lowered ceiling,
@@ -874,6 +1012,12 @@ export class SqlAccountSourceSyncStore {
874
1012
  const expiresAt = Math.min(holding.dependency.expiresAt, read.dependency.expiresAt, ...messages.map((m) => m.windowExpiresAt));
875
1013
  shortened = { ...holding, messages, dependency: { ...holding.dependency, expiresAt } };
876
1014
  }
1015
+ else if (period !== null) {
1016
+ // With no copy left, nothing a later read of the source decides needs it soon: `page` settles the source
1017
+ // as this skip with no read until the period turns, the room grows, the generation moves or a reconcile
1018
+ // interval has passed. A kept copy is read again at each listing, so a change at the source reaches it.
1019
+ deferral = { generation: h.lease.generation, limit: room, at: now };
1020
+ }
877
1021
  }
878
1022
  else {
879
1023
  refreshed = holding !== undefined && sameCopy(holding, read)
@@ -922,6 +1066,18 @@ export class SqlAccountSourceSyncStore {
922
1066
  VALUES($1,$2,$3,$4,$5::jsonb,$6)`, [...params(s), work.sourceId,
923
1067
  JSON.stringify(normalized), normalized.dependency.expiresAt]);
924
1068
  }
1069
+ // The period's record takes the text this application wrote or refreshed, which the index holds for the source
1070
+ // in the period, or the deferral beside the text the period already charged for the source, if it charged one: a
1071
+ // read once the room grows is weighed against that text, so the period never charges it twice. A source the period
1072
+ // never indexed keeps the deferral alone, with no message ids or hashes. A source the provider answers gone or
1073
+ // skipped keeps no record, which the withdrawal before this transaction removed: the provider has said the store
1074
+ // may not hold it. The store's own skips keep it, since the source may fit again in the period; an exclusion
1075
+ // removed it in `exclude`.
1076
+ if (period !== null && normalized !== null)
1077
+ await this.recordCharge(tx, s, work.sourceId, period, normalized, null);
1078
+ else if (period !== null && deferral !== null) {
1079
+ await this.recordCharge(tx, s, work.sourceId, period, record?.charged ?? null, deferral);
1080
+ }
925
1081
  // A slow binding cannot extend permission or finish after its lease expired.
926
1082
  if (normalized && normalized.dependency.expiresAt <= this.now())
927
1083
  throw new StaleSync();
@@ -942,7 +1098,7 @@ export class SqlAccountSourceSyncStore {
942
1098
  catch (error) {
943
1099
  if (!(error instanceof WithdrawFirst))
944
1100
  throw error;
945
- if (await this.withdraw(h, work, { kind: 'skipped', id: work.sourceId, reason: 'expired' }) === null)
1101
+ if (await this.withdraw(h, work, { kind: 'skipped', id: work.sourceId, reason: 'expired' }, false) === null)
946
1102
  return null;
947
1103
  return apply(true);
948
1104
  }
@@ -957,13 +1113,15 @@ export class SqlAccountSourceSyncStore {
957
1113
  * before the one that removes it: the copy's expiry, and its dependency's, move to the store's clock. `readCurrent`
958
1114
  * then answers it expired, so the fence neither commits nor delivers a result derived from it, and `expire` and
959
1115
  * `sweepExpired` remove it should the removal fail. The handle, the work item and the result are checked as
960
- * `applyResult` checks them, and a copy already past its expiry is left as it is.
1116
+ * `applyResult` checks them, and a copy already past its expiry is left as it is. With `forget`, for a provider's
1117
+ * answer, the source's charge record goes in the same transaction, so its message ids and hashes are gone whatever
1118
+ * the removal after it does; the store's own `expired` skip keeps it.
961
1119
  *
962
1120
  * @returns True once the copy is withdrawn or none is held within its expiry; null for a stale handle or work item.
963
1121
  * @throws {SourceSyncFault} `invalid_response` for a result that does not match its work item or a skip reason the
964
1122
  * store does not know, with nothing withdrawn.
965
1123
  */
966
- async withdraw(h, work, result) {
1124
+ async withdraw(h, work, result, forget) {
967
1125
  return this.withHandle(h, async (tx, row) => {
968
1126
  const s = h.lease.scope;
969
1127
  await this.currentWork(tx, s, row.checkpoint, work);
@@ -980,6 +1138,8 @@ export class SqlAccountSourceSyncStore {
980
1138
  await tx.run(`UPDATE ${this.t.documents} SET document=$5::jsonb,expires_at=$6
981
1139
  WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=$4`, [...params(s), work.sourceId, JSON.stringify(withdrawn), now]);
982
1140
  }
1141
+ if (forget)
1142
+ await this.forgetCharge(tx, s, work.sourceId);
983
1143
  return true;
984
1144
  });
985
1145
  }
@@ -1250,13 +1410,22 @@ export class SqlAccountSourceSyncStore {
1250
1410
  }
1251
1411
  return states;
1252
1412
  }
1253
- /** Removes the host's content and this store's provenance in the affected scope; the fence calls it inside an invalidation. */
1413
+ /**
1414
+ * Removes the host's content and this store's provenance in the affected scope; the fence calls it inside an
1415
+ * invalidation. A connection's or an owner's purge removes their charge records too. One source's purge, which its
1416
+ * expiry and every replacement of its copy run, leaves the source's record: the record outlives the copy until its
1417
+ * period ends, and `exclude` and a provider's `gone` or skip remove it themselves.
1418
+ */
1254
1419
  async purgeSources(tx, scope, affected) {
1255
1420
  await this.options.bindings.purge(tx, scope, affected);
1256
1421
  const args = [scope.tenantId, scope.ownerId, affected.kind === 'owner' ? null : affected.connectionId,
1257
1422
  affected.kind === 'source' ? affected.sourceId : null];
1258
1423
  await tx.run(`DELETE FROM ${this.t.documents} WHERE tenant_id=$1 AND owner_id=$2
1259
1424
  AND ($3::text IS NULL OR connection_id=$3) AND ($4::text IS NULL OR source_id=$4)`, args);
1425
+ if ((affected.kind === 'connection' || affected.kind === 'owner') && await this.chargesHeld(tx)) {
1426
+ await tx.run(`DELETE FROM ${this.t.charges} WHERE tenant_id=$1 AND owner_id=$2
1427
+ AND ($3::text IS NULL OR connection_id=$3)`, args.slice(0, 3));
1428
+ }
1260
1429
  }
1261
1430
  /**
1262
1431
  * Applies a host control: completes any earlier barrier, then blocks the owner, moves the connection, records a
@@ -1406,9 +1575,7 @@ export class SqlAccountSourceSyncStore {
1406
1575
  await remove(barrier.sourceId);
1407
1576
  else if (barrier.action === 'prune') {
1408
1577
  const docs = await this.documentRows(tx, scope);
1409
- const allowance = await this.options.bindings.allowance(tx, scope);
1410
- if (!Object.values(allowance).every(validNumber))
1411
- throw new Error('invalid_source_allowance');
1578
+ const allowance = checkedAllowance(await this.options.bindings.allowance(tx, scope));
1412
1579
  const kept = this.choose(docs, row.policy, allowance.maximumConnectionDocuments, allowance.maximumConnectionHeldCharacters, this.now());
1413
1580
  for (const doc of docs)
1414
1581
  if (!kept.includes(doc))
@@ -1442,8 +1609,9 @@ export class SqlAccountSourceSyncStore {
1442
1609
  }
1443
1610
  /**
1444
1611
  * Removes one source and every result derived from it, and keeps a content-free exclusion so later syncs skip it.
1445
- * The connection moves to a new generation and its open cycle is dropped. Its committed cursor is kept, and the next
1446
- * cycle is a reconcile: a fresh bounded scan, then history replayed from that cursor.
1612
+ * The source's charge record goes in the same transaction as the exclusion. The connection moves to a new generation
1613
+ * and its open cycle is dropped. Its committed cursor is kept, and the next cycle is a reconcile: a fresh bounded
1614
+ * scan, then history replayed from that cursor.
1447
1615
  *
1448
1616
  * @throws {SourceSyncFault} `invalid_response` for an empty id or one longer than 512 characters.
1449
1617
  */
@@ -1460,6 +1628,7 @@ export class SqlAccountSourceSyncStore {
1460
1628
  this.exclusionsChanged(row);
1461
1629
  await this.clearCycle(tx, scope, row);
1462
1630
  await tx.run(`INSERT INTO ${this.t.exclusions} VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING`, [...params(scope), sourceId]);
1631
+ await this.forgetCharge(tx, scope, sourceId);
1463
1632
  row.barrier = { token: randomUUID(), action: 'exclude', sourceId };
1464
1633
  await this.rebind(tx, scope, row.state.generation);
1465
1634
  await this.save(tx, scope, row);
@@ -1492,13 +1661,15 @@ export class SqlAccountSourceSyncStore {
1492
1661
  * Removes retained sources whose expiry has passed, with every result derived from them, also while the connection
1493
1662
  * is paused or the owner's fence is inactive, then expires the owner's derived results. An inactive owner, such as a
1494
1663
  * suspended account or one a control's barrier holds, stays inactive; reads refuse an expired source regardless.
1495
- * Each source goes in a transaction of its own, so a later failure leaves the removals before it in place.
1664
+ * Each source goes in a transaction of its own, so a later failure leaves the removals before it in place. Last, it
1665
+ * removes up to `limit` of the connection's charge records whose indexing period has ended by the store's clock.
1496
1666
  *
1497
- * @param limit - At most this many sources, an integer from 1 to 1,000.
1667
+ * @param limit - At most this many sources, and this many ended charge records, an integer from 1 to 1,000.
1498
1668
  * @returns How many sources were removed.
1499
- * @throws {Error} `invalid_sync_batch` for another limit, and the failure itself when a removal or the results'
1500
- * expiry fails: once a source was removed, a `SourceSyncExpiryError` with the failure's message, the failure as its
1501
- * `cause` and the removals made before it in `partial`, counted in `removed` and named in `sourceIds`.
1669
+ * @throws {Error} `invalid_sync_batch` for another limit, and the failure itself when a removal, the results' expiry
1670
+ * or the removal of ended charge records fails: once a source was removed, a `SourceSyncExpiryError` with the
1671
+ * failure's message, the failure as its `cause` and the removals made before it in `partial`, counted in `removed`
1672
+ * and named in `sourceIds`.
1502
1673
  */
1503
1674
  async expire(scope, limit = 100) {
1504
1675
  if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
@@ -1512,6 +1683,7 @@ export class SqlAccountSourceSyncStore {
1512
1683
  if (await this.expireSource(scope, row.source_id))
1513
1684
  removed.push(row.source_id);
1514
1685
  await this.fence.expireResults(scope, limit);
1686
+ await this.forgetEnded(scope, scope.connectionId, limit);
1515
1687
  }
1516
1688
  catch (error) {
1517
1689
  // The removals before the failure have committed, so their count and their sources go to the caller with it.
@@ -1547,11 +1719,14 @@ export class SqlAccountSourceSyncStore {
1547
1719
  * indexing skip that keeps a copy may have shortened. An owner whose removal fails, as when its host purge keeps
1548
1720
  * failing, is counted and left out of the call's later reads, so a call with up to `limit` such owners never holds
1549
1721
  * back another owner's copies. An owner whose derived results then fail to expire is counted too, once; the call
1550
- * still answers every source it removed, and the other owners' results still expire.
1722
+ * still answers every source it removed, and the other owners' results still expire. Last, it removes up to `limit`
1723
+ * charge records whose indexing period has ended by the store's clock, every owner's or `owner`'s, the earliest end
1724
+ * first; a failure there counts once, after every copy the call could remove has gone.
1551
1725
  *
1552
- * @param limit - At most this many sources, an integer from 1 to 1,000.
1726
+ * @param limit - At most this many sources, and this many ended charge records, an integer from 1 to 1,000.
1553
1727
  * @param owner - The owner whose sources alone are swept, or null for every owner.
1554
- * @returns The sources removed, and the owners whose removal or whose derived results' expiry failed.
1728
+ * @returns The sources removed, and the owners whose removal or whose derived results' expiry failed, with one more
1729
+ * when the removal of ended charge records failed.
1555
1730
  * @throws {Error} `invalid_sync_batch` for another limit.
1556
1731
  */
1557
1732
  async sweepExpired(limit = 100, owner = null) {
@@ -1605,12 +1780,19 @@ export class SqlAccountSourceSyncStore {
1605
1780
  failed++;
1606
1781
  }
1607
1782
  }
1783
+ // A record of an ended period is weighed against nothing, so it goes once the copies have.
1784
+ try {
1785
+ await this.forgetEnded(owner, null, limit);
1786
+ }
1787
+ catch {
1788
+ failed++;
1789
+ }
1608
1790
  return { removed, failed };
1609
1791
  }
1610
1792
  /**
1611
1793
  * Removes a stopped owner's rows from the store in one transaction: every connection of the owner, with its work
1612
- * rows, retained-source provenance and exclusions, once each connection is disconnected with no barrier, lease, work
1613
- * or retained source. `remove` runs first in the same transaction, after the owner fence's row lock and the
1794
+ * rows, retained-source provenance, exclusions and charge records, once each connection is disconnected with no
1795
+ * barrier, lease, work or retained source. `remove` runs first in the same transaction, after the owner fence's row lock and the
1614
1796
  * connections' row locks, for a lifecycle's own rows; `SourceRevocations.removeOwner` passes its own. The core
1615
1797
  * fence's owner row and its deletion records stay: they hold no content, keep the owner's epoch ahead of any old
1616
1798
  * handle, and a restore replays the deletion from them.
@@ -1636,7 +1818,7 @@ export class SqlAccountSourceSyncStore {
1636
1818
  }
1637
1819
  if (!await remove(tx))
1638
1820
  throw new StaleSync();
1639
- // The work rows, provenance and exclusions go with their connection (ON DELETE CASCADE).
1821
+ // The work rows, provenance, exclusions and charge records go with their connection (ON DELETE CASCADE).
1640
1822
  await tx.run(`DELETE FROM ${this.t.connections} WHERE tenant_id=$1 AND owner_id=$2`, where);
1641
1823
  });
1642
1824
  return true;