@framers/agentos-ext-account-sources 0.10.0 → 0.11.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.
@@ -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,15 @@ 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 alone: with no copy held, a deferred source keeps no message ids or hashes. A
1071
+ // source the provider answers gone or skipped keeps no record, which the withdrawal before this transaction
1072
+ // removed: the provider has said the store may not hold it. The store's own skips keep it, since the source may
1073
+ // fit again in the period; an exclusion removed it in `exclude`.
1074
+ if (period !== null && normalized !== null)
1075
+ await this.recordCharge(tx, s, work.sourceId, period, normalized, null);
1076
+ else if (period !== null && deferral !== null)
1077
+ await this.recordCharge(tx, s, work.sourceId, period, null, deferral);
925
1078
  // A slow binding cannot extend permission or finish after its lease expired.
926
1079
  if (normalized && normalized.dependency.expiresAt <= this.now())
927
1080
  throw new StaleSync();
@@ -942,7 +1095,7 @@ export class SqlAccountSourceSyncStore {
942
1095
  catch (error) {
943
1096
  if (!(error instanceof WithdrawFirst))
944
1097
  throw error;
945
- if (await this.withdraw(h, work, { kind: 'skipped', id: work.sourceId, reason: 'expired' }) === null)
1098
+ if (await this.withdraw(h, work, { kind: 'skipped', id: work.sourceId, reason: 'expired' }, false) === null)
946
1099
  return null;
947
1100
  return apply(true);
948
1101
  }
@@ -957,13 +1110,15 @@ export class SqlAccountSourceSyncStore {
957
1110
  * before the one that removes it: the copy's expiry, and its dependency's, move to the store's clock. `readCurrent`
958
1111
  * then answers it expired, so the fence neither commits nor delivers a result derived from it, and `expire` and
959
1112
  * `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.
1113
+ * `applyResult` checks them, and a copy already past its expiry is left as it is. With `forget`, for a provider's
1114
+ * answer, the source's charge record goes in the same transaction, so its message ids and hashes are gone whatever
1115
+ * the removal after it does; the store's own `expired` skip keeps it.
961
1116
  *
962
1117
  * @returns True once the copy is withdrawn or none is held within its expiry; null for a stale handle or work item.
963
1118
  * @throws {SourceSyncFault} `invalid_response` for a result that does not match its work item or a skip reason the
964
1119
  * store does not know, with nothing withdrawn.
965
1120
  */
966
- async withdraw(h, work, result) {
1121
+ async withdraw(h, work, result, forget) {
967
1122
  return this.withHandle(h, async (tx, row) => {
968
1123
  const s = h.lease.scope;
969
1124
  await this.currentWork(tx, s, row.checkpoint, work);
@@ -980,6 +1135,8 @@ export class SqlAccountSourceSyncStore {
980
1135
  await tx.run(`UPDATE ${this.t.documents} SET document=$5::jsonb,expires_at=$6
981
1136
  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
1137
  }
1138
+ if (forget)
1139
+ await this.forgetCharge(tx, s, work.sourceId);
983
1140
  return true;
984
1141
  });
985
1142
  }
@@ -1250,13 +1407,22 @@ export class SqlAccountSourceSyncStore {
1250
1407
  }
1251
1408
  return states;
1252
1409
  }
1253
- /** Removes the host's content and this store's provenance in the affected scope; the fence calls it inside an invalidation. */
1410
+ /**
1411
+ * Removes the host's content and this store's provenance in the affected scope; the fence calls it inside an
1412
+ * invalidation. A connection's or an owner's purge removes their charge records too. One source's purge, which its
1413
+ * expiry and every replacement of its copy run, leaves the source's record: the record outlives the copy until its
1414
+ * period ends, and `exclude` and a provider's `gone` or skip remove it themselves.
1415
+ */
1254
1416
  async purgeSources(tx, scope, affected) {
1255
1417
  await this.options.bindings.purge(tx, scope, affected);
1256
1418
  const args = [scope.tenantId, scope.ownerId, affected.kind === 'owner' ? null : affected.connectionId,
1257
1419
  affected.kind === 'source' ? affected.sourceId : null];
1258
1420
  await tx.run(`DELETE FROM ${this.t.documents} WHERE tenant_id=$1 AND owner_id=$2
1259
1421
  AND ($3::text IS NULL OR connection_id=$3) AND ($4::text IS NULL OR source_id=$4)`, args);
1422
+ if ((affected.kind === 'connection' || affected.kind === 'owner') && await this.chargesHeld(tx)) {
1423
+ await tx.run(`DELETE FROM ${this.t.charges} WHERE tenant_id=$1 AND owner_id=$2
1424
+ AND ($3::text IS NULL OR connection_id=$3)`, args.slice(0, 3));
1425
+ }
1260
1426
  }
1261
1427
  /**
1262
1428
  * Applies a host control: completes any earlier barrier, then blocks the owner, moves the connection, records a
@@ -1406,9 +1572,7 @@ export class SqlAccountSourceSyncStore {
1406
1572
  await remove(barrier.sourceId);
1407
1573
  else if (barrier.action === 'prune') {
1408
1574
  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');
1575
+ const allowance = checkedAllowance(await this.options.bindings.allowance(tx, scope));
1412
1576
  const kept = this.choose(docs, row.policy, allowance.maximumConnectionDocuments, allowance.maximumConnectionHeldCharacters, this.now());
1413
1577
  for (const doc of docs)
1414
1578
  if (!kept.includes(doc))
@@ -1442,8 +1606,9 @@ export class SqlAccountSourceSyncStore {
1442
1606
  }
1443
1607
  /**
1444
1608
  * 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.
1609
+ * The source's charge record goes in the same transaction as the exclusion. The connection moves to a new generation
1610
+ * and its open cycle is dropped. Its committed cursor is kept, and the next cycle is a reconcile: a fresh bounded
1611
+ * scan, then history replayed from that cursor.
1447
1612
  *
1448
1613
  * @throws {SourceSyncFault} `invalid_response` for an empty id or one longer than 512 characters.
1449
1614
  */
@@ -1460,6 +1625,7 @@ export class SqlAccountSourceSyncStore {
1460
1625
  this.exclusionsChanged(row);
1461
1626
  await this.clearCycle(tx, scope, row);
1462
1627
  await tx.run(`INSERT INTO ${this.t.exclusions} VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING`, [...params(scope), sourceId]);
1628
+ await this.forgetCharge(tx, scope, sourceId);
1463
1629
  row.barrier = { token: randomUUID(), action: 'exclude', sourceId };
1464
1630
  await this.rebind(tx, scope, row.state.generation);
1465
1631
  await this.save(tx, scope, row);
@@ -1492,13 +1658,15 @@ export class SqlAccountSourceSyncStore {
1492
1658
  * Removes retained sources whose expiry has passed, with every result derived from them, also while the connection
1493
1659
  * is paused or the owner's fence is inactive, then expires the owner's derived results. An inactive owner, such as a
1494
1660
  * 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.
1661
+ * Each source goes in a transaction of its own, so a later failure leaves the removals before it in place. Last, it
1662
+ * removes up to `limit` of the connection's charge records whose indexing period has ended by the store's clock.
1496
1663
  *
1497
- * @param limit - At most this many sources, an integer from 1 to 1,000.
1664
+ * @param limit - At most this many sources, and this many ended charge records, an integer from 1 to 1,000.
1498
1665
  * @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`.
1666
+ * @throws {Error} `invalid_sync_batch` for another limit, and the failure itself when a removal, the results' expiry
1667
+ * or the removal of ended charge records fails: once a source was removed, a `SourceSyncExpiryError` with the
1668
+ * failure's message, the failure as its `cause` and the removals made before it in `partial`, counted in `removed`
1669
+ * and named in `sourceIds`.
1502
1670
  */
1503
1671
  async expire(scope, limit = 100) {
1504
1672
  if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
@@ -1512,6 +1680,7 @@ export class SqlAccountSourceSyncStore {
1512
1680
  if (await this.expireSource(scope, row.source_id))
1513
1681
  removed.push(row.source_id);
1514
1682
  await this.fence.expireResults(scope, limit);
1683
+ await this.forgetEnded(scope, scope.connectionId, limit);
1515
1684
  }
1516
1685
  catch (error) {
1517
1686
  // The removals before the failure have committed, so their count and their sources go to the caller with it.
@@ -1547,11 +1716,14 @@ export class SqlAccountSourceSyncStore {
1547
1716
  * indexing skip that keeps a copy may have shortened. An owner whose removal fails, as when its host purge keeps
1548
1717
  * failing, is counted and left out of the call's later reads, so a call with up to `limit` such owners never holds
1549
1718
  * 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.
1719
+ * still answers every source it removed, and the other owners' results still expire. Last, it removes up to `limit`
1720
+ * charge records whose indexing period has ended by the store's clock, every owner's or `owner`'s, the earliest end
1721
+ * first; a failure there counts once, after every copy the call could remove has gone.
1551
1722
  *
1552
- * @param limit - At most this many sources, an integer from 1 to 1,000.
1723
+ * @param limit - At most this many sources, and this many ended charge records, an integer from 1 to 1,000.
1553
1724
  * @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.
1725
+ * @returns The sources removed, and the owners whose removal or whose derived results' expiry failed, with one more
1726
+ * when the removal of ended charge records failed.
1555
1727
  * @throws {Error} `invalid_sync_batch` for another limit.
1556
1728
  */
1557
1729
  async sweepExpired(limit = 100, owner = null) {
@@ -1605,12 +1777,19 @@ export class SqlAccountSourceSyncStore {
1605
1777
  failed++;
1606
1778
  }
1607
1779
  }
1780
+ // A record of an ended period is weighed against nothing, so it goes once the copies have.
1781
+ try {
1782
+ await this.forgetEnded(owner, null, limit);
1783
+ }
1784
+ catch {
1785
+ failed++;
1786
+ }
1608
1787
  return { removed, failed };
1609
1788
  }
1610
1789
  /**
1611
1790
  * 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
1791
+ * rows, retained-source provenance, exclusions and charge records, once each connection is disconnected with no
1792
+ * barrier, lease, work or retained source. `remove` runs first in the same transaction, after the owner fence's row lock and the
1614
1793
  * connections' row locks, for a lifecycle's own rows; `SourceRevocations.removeOwner` passes its own. The core
1615
1794
  * fence's owner row and its deletion records stay: they hold no content, keep the owner's epoch ahead of any old
1616
1795
  * handle, and a restore replays the deletion from them.
@@ -1636,7 +1815,7 @@ export class SqlAccountSourceSyncStore {
1636
1815
  }
1637
1816
  if (!await remove(tx))
1638
1817
  throw new StaleSync();
1639
- // The work rows, provenance and exclusions go with their connection (ON DELETE CASCADE).
1818
+ // The work rows, provenance, exclusions and charge records go with their connection (ON DELETE CASCADE).
1640
1819
  await tx.run(`DELETE FROM ${this.t.connections} WHERE tenant_id=$1 AND owner_id=$2`, where);
1641
1820
  });
1642
1821
  return true;