@framers/agentos-ext-account-sources 0.3.0 → 0.5.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.
@@ -0,0 +1,751 @@
1
+ /**
2
+ * @fileoverview The durable source synchronization store on PostgreSQL through `@framers/sql-storage-adapter`:
3
+ * connection state, leases, checkpoints, work rows and the provenance of retained documents, with every change to
4
+ * an owner's content made under that owner's fence from the core derivation store.
5
+ * @module account-sources/sync/SqlAccountSourceSyncStore
6
+ */
7
+ import { createHash, randomUUID } from 'node:crypto';
8
+ import { SqlSourceDerivationStore, createSourceDerivation, } from '@framers/agentos/cognition/library';
9
+ import { createSourceSyncState, transitionSourceSync, sourceSyncDue, openSourceSyncCycle, advanceSourceSyncPage, committableSourceCursor, assertSourceSyncPolicy, SourceSyncFault, } from './state.js';
10
+ import { sourceSyncTables } from './schema.js';
11
+ class StaleSync extends Error {
12
+ constructor() { super('stale_source_sync'); }
13
+ }
14
+ const params = (s) => [s.tenantId, s.ownerId, s.connectionId];
15
+ const validNumber = (n) => Number.isSafeInteger(n) && n >= 0;
16
+ const validId = (s) => typeof s === 'string' && s.length > 0 && s.length <= 512;
17
+ 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
+ const newest = (a, b) => b.activityAt - a.activityAt || (a.dependency.sourceId < b.dependency.sourceId ? -1
19
+ : a.dependency.sourceId > b.dependency.sourceId ? 1 : 0);
20
+ /**
21
+ * Durable synchronization state for account sources on PostgreSQL. Every change to an owner's content runs under
22
+ * 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 before the
24
+ * replacement is written. Provider reads, embedding and model calls stay outside the store. The constructor runs no
25
+ * DDL: the host's migration runs `sourceSyncSchema` and core's `sourceDerivationSchema` first.
26
+ *
27
+ * @typeParam Prepared - The host's in-memory value for one document, passed through to its `write` binding.
28
+ */
29
+ export class SqlAccountSourceSyncStore {
30
+ db;
31
+ options;
32
+ t;
33
+ clock;
34
+ /**
35
+ * The owner fence. The same instance commits and delivers derived results and fences account deletion. Host code
36
+ * never activates an owner through it with a callback that answers true unconditionally.
37
+ */
38
+ fence;
39
+ /**
40
+ * @param db - A PostgreSQL adapter with transactions.
41
+ * @param options - Table prefixes, clock and the host's bindings.
42
+ * @throws {Error} `source_sync_requires_postgres` for another kind of adapter or one without transactions, and
43
+ * `invalid_source_sync_prefix` or core's `invalid_derivation_table_prefix` for a malformed prefix.
44
+ */
45
+ constructor(db, options) {
46
+ this.db = db;
47
+ this.options = options;
48
+ if (db.kind !== 'postgres' || !db.capabilities.has('transactions'))
49
+ throw new Error('source_sync_requires_postgres');
50
+ this.t = sourceSyncTables(options.tablePrefix);
51
+ this.clock = options.clock ?? Date.now;
52
+ this.fence = this.fenceOn(db);
53
+ }
54
+ now() {
55
+ const n = this.clock();
56
+ if (!validNumber(n))
57
+ throw new Error('invalid_source_sync_clock');
58
+ return n;
59
+ }
60
+ fenceOn(db) {
61
+ return new SqlSourceDerivationStore(db, {
62
+ tablePrefix: this.options.derivationPrefix, clock: this.clock,
63
+ bindings: {
64
+ readCurrent: (tx, derivation) => this.readCurrent(tx, derivation),
65
+ purgeResults: (tx, scope, ids) => this.options.bindings.purgeResults(tx, scope, ids),
66
+ purgeSources: (tx, scope, affected) => this.purgeSources(tx, scope, affected),
67
+ },
68
+ });
69
+ }
70
+ async row(tx, scope) {
71
+ const row = await tx.get(`SELECT * FROM ${this.t.connections}
72
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 FOR UPDATE`, params(scope));
73
+ if (!row)
74
+ throw new StaleSync();
75
+ return row;
76
+ }
77
+ async save(tx, scope, row) {
78
+ await tx.run(`UPDATE ${this.t.connections} SET state=$4::jsonb,policy=$5::jsonb,
79
+ checkpoint=$6::jsonb,barrier=$7::jsonb,lease_token=$8,lease_until=$9,last_page=$10,
80
+ last_reconcile=$11,due_at=$12 WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3`, [...params(scope), JSON.stringify(row.state), JSON.stringify(row.policy),
81
+ row.checkpoint ? JSON.stringify(row.checkpoint) : null, row.barrier ? JSON.stringify(row.barrier) : null,
82
+ row.lease_token, row.lease_until, row.last_page, row.last_reconcile, row.state.nextSyncAt]);
83
+ }
84
+ async clearCycle(tx, scope, row) {
85
+ row.checkpoint = null;
86
+ row.last_page = null;
87
+ row.lease_token = null;
88
+ row.lease_until = null;
89
+ await tx.run(`DELETE FROM ${this.t.work} WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3`, params(scope));
90
+ }
91
+ async activate(scope) {
92
+ await this.fence.activateOwner(scope, async (tx) => {
93
+ if (!await this.options.bindings.active(tx, scope))
94
+ return false;
95
+ const barrier = await tx.get(`SELECT 1 FROM ${this.t.connections}
96
+ WHERE tenant_id=$1 AND owner_id=$2 AND barrier IS NOT NULL LIMIT 1`, [scope.tenantId, scope.ownerId]);
97
+ return barrier === null;
98
+ });
99
+ }
100
+ /**
101
+ * Creates the connection in `not_connected` and activates its owner when the host's `active` binding allows it and
102
+ * no barrier of the owner is pending; otherwise nothing changes. An existing connection is kept as it is.
103
+ *
104
+ * @throws {Error} `invalid_source_sync_policy` for an invalid policy and `invalid_source_sync_state` for an
105
+ * invalid scope.
106
+ */
107
+ async create(scope, policy) {
108
+ assertSourceSyncPolicy(policy);
109
+ const state = createSourceSyncState(scope, this.now());
110
+ await this.fence.activateOwner(scope, async (tx) => {
111
+ if (!await this.options.bindings.active(tx, scope))
112
+ return false;
113
+ if (await tx.get(`SELECT 1 FROM ${this.t.connections} WHERE tenant_id=$1 AND owner_id=$2
114
+ AND barrier IS NOT NULL LIMIT 1`, [scope.tenantId, scope.ownerId]))
115
+ return false;
116
+ await tx.run(`INSERT INTO ${this.t.connections}(tenant_id,owner_id,connection_id,state,policy)
117
+ VALUES($1,$2,$3,$4::jsonb,$5::jsonb) ON CONFLICT DO NOTHING`, [...params(scope), JSON.stringify(state), JSON.stringify(policy)]);
118
+ return true;
119
+ });
120
+ }
121
+ /** Metadata for authenticated host controls only; no content-delivery authorization. */
122
+ async snapshot(scope) {
123
+ const row = await this.db.get(`SELECT * FROM ${this.t.connections}
124
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3`, params(scope));
125
+ return row ? { state: row.state, policy: row.policy, checkpoint: row.checkpoint,
126
+ barrier: row.barrier, lastReconcileAt: row.last_reconcile === null ? null : Number(row.last_reconcile) } : null;
127
+ }
128
+ /** Operator scheduler only. The claim performs fresh host and owner checks. */
129
+ async scopes(after = null, limit = 100) { if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
130
+ throw new Error("invalid_sync_batch"); const rows = await this.db.all(`SELECT state FROM ${this.t.connections} WHERE ($1::text IS NULL OR (tenant_id,owner_id,connection_id)>($1,$2,$3)) ORDER BY tenant_id,owner_id,connection_id LIMIT $4`, [after?.tenantId ?? null, after?.ownerId ?? null, after?.connectionId ?? null, limit]); return rows.map((row) => row.state.scope); }
131
+ /**
132
+ * Connections whose next sync time has come, with no barrier and no live lease, earliest first.
133
+ *
134
+ * @param limit - At most this many, an integer from 1 to 1,000.
135
+ * @throws {Error} `invalid_sync_batch` for another limit.
136
+ */
137
+ async due(limit = 100) {
138
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
139
+ throw new Error('invalid_sync_batch');
140
+ const rows = await this.db.all(`SELECT state FROM ${this.t.connections}
141
+ WHERE due_at <= $1 AND barrier IS NULL AND (lease_until IS NULL OR lease_until <= $1)
142
+ ORDER BY due_at,tenant_id,owner_id,connection_id LIMIT $2`, [this.now(), limit]);
143
+ return rows.map((r) => r.state.scope);
144
+ }
145
+ /**
146
+ * Claims the connection's lease for one sync. The owner must be active, the host's `active` binding must allow it,
147
+ * no barrier or live lease may be set and the connection must be due for `trigger`; a manual claim records the
148
+ * request.
149
+ *
150
+ * @returns The handle, or null.
151
+ */
152
+ async claim(scope, trigger) {
153
+ const ticket = await this.fence.capture(scope);
154
+ if (!ticket)
155
+ return null;
156
+ try {
157
+ const result = await this.fence.apply(ticket, async (tx) => {
158
+ const row = await this.row(tx, scope);
159
+ const now = this.now();
160
+ if (row.barrier || !await this.options.bindings.active(tx, scope)
161
+ || (row.lease_until !== null && Number(row.lease_until) > now)
162
+ || !sourceSyncDue(row.state, row.policy, now, trigger))
163
+ return null;
164
+ if (trigger === 'manual')
165
+ row.state = transitionSourceSync(row.state, { type: 'manual_requested' }, now);
166
+ row.state = transitionSourceSync(row.state, { type: 'sync_started' }, now);
167
+ row.lease_token = randomUUID();
168
+ row.lease_until = now + row.policy.leaseMs;
169
+ await this.save(tx, scope, row);
170
+ return { ticket, lease: { scope, generation: row.state.generation,
171
+ token: row.lease_token, expiresAt: Number(row.lease_until) } };
172
+ });
173
+ return result.kind === 'applied' ? result.value : null;
174
+ }
175
+ catch (error) {
176
+ if (error instanceof StaleSync)
177
+ return null;
178
+ throw error;
179
+ }
180
+ }
181
+ async checked(tx, h) {
182
+ const s = h.lease.scope;
183
+ if (s.tenantId !== h.ticket.tenantId || s.ownerId !== h.ticket.ownerId)
184
+ throw new StaleSync();
185
+ const row = await this.row(tx, s);
186
+ if (row.barrier || row.state.paused || row.state.status !== 'syncing'
187
+ || row.state.generation !== h.lease.generation || row.lease_token !== h.lease.token
188
+ || row.lease_until === null || Number(row.lease_until) <= this.now()
189
+ || !await this.options.bindings.active(tx, s))
190
+ throw new StaleSync();
191
+ return row;
192
+ }
193
+ async withHandle(h, work) {
194
+ try {
195
+ const result = await this.fence.apply(h.ticket, async (tx) => work(tx, await this.checked(tx, h)));
196
+ return result.kind === 'applied' ? result.value : null;
197
+ }
198
+ catch (error) {
199
+ if (error instanceof StaleSync)
200
+ return null;
201
+ throw error;
202
+ }
203
+ }
204
+ /**
205
+ * Extends a live lease by the policy's `leaseMs`.
206
+ *
207
+ * @returns The renewed handle, or null when the lease, its generation or the owner fence has moved on.
208
+ */
209
+ async renew(h) {
210
+ return this.withHandle(h, async (tx, row) => {
211
+ row.lease_until = this.now() + row.policy.leaseMs;
212
+ await this.save(tx, h.lease.scope, row);
213
+ return { ...h, lease: { ...h.lease, expiresAt: Number(row.lease_until) } };
214
+ });
215
+ }
216
+ /**
217
+ * Opens a cycle's durable checkpoint and drops the previous cycle's work, or answers the checkpoint already open.
218
+ * An incremental cycle starts from the committed cursor. With `replaceExpiredHistory`, a `recovery` cycle replaces
219
+ * the open one. The cutoff defaults to the connection's last change.
220
+ *
221
+ * @returns The checkpoint, or null for a stale handle or an incremental cycle from another cursor.
222
+ * @throws {SourceSyncFault} `invalid_response` for a cutoff in the future or a replacement that is not `recovery`.
223
+ */
224
+ async openCycle(h, input, replaceExpiredHistory = false) {
225
+ return this.withHandle(h, async (tx, row) => {
226
+ if (row.checkpoint && !replaceExpiredHistory)
227
+ return row.checkpoint;
228
+ if (replaceExpiredHistory && input.mode !== 'recovery')
229
+ throw new SourceSyncFault('invalid_response');
230
+ if (input.mode === 'incremental' && input.fromCursor !== row.state.cursor)
231
+ throw new StaleSync();
232
+ if (input.cutoff !== undefined && (!validNumber(input.cutoff) || input.cutoff > this.now()))
233
+ throw new SourceSyncFault("invalid_response");
234
+ await tx.run(`DELETE FROM ${this.t.work} WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3`, params(h.lease.scope));
235
+ row.checkpoint = openSourceSyncCycle({ ...input, cycleId: input.cycleId ?? randomUUID(),
236
+ generation: row.state.generation, cutoff: input.cutoff ?? row.state.updatedAt });
237
+ row.last_page = null;
238
+ await this.save(tx, h.lease.scope, row);
239
+ return row.checkpoint;
240
+ });
241
+ }
242
+ /**
243
+ * Records one page: the next checkpoint and its queued work, in one transaction. The same page sent again right
244
+ * after it was recorded answers the same checkpoint; any other stale revision answers null.
245
+ *
246
+ * @throws {SourceSyncFault} `invalid_response` for an id longer than 512 characters, and the page rules'
247
+ * `invalid_source_sync_page` for a page the checkpoint does not admit.
248
+ */
249
+ async page(h, expected, page) {
250
+ return this.withHandle(h, async (tx, row) => {
251
+ const cp = row.checkpoint;
252
+ if (!cp || cp.cycleId !== expected.cycleId || cp.generation !== expected.generation)
253
+ throw new StaleSync();
254
+ const digest = createHash('sha256').update(JSON.stringify({
255
+ cycle: expected.cycleId, revision: expected.revision, kind: page.kind, ids: page.ids,
256
+ next: page.nextPageToken, cursor: page.kind === 'history' ? page.cursor : null,
257
+ })).digest('hex');
258
+ if (cp.revision === expected.revision + 1 && row.last_page === digest)
259
+ return cp;
260
+ if (cp.revision !== expected.revision)
261
+ throw new StaleSync();
262
+ const next = advanceSourceSyncPage(cp, page, row.policy);
263
+ for (const id of next.enqueueIds) {
264
+ if (!validId(id))
265
+ throw new SourceSyncFault('invalid_response');
266
+ const work = { cycleId: cp.cycleId, generation: cp.generation, sourceId: id,
267
+ discoveredRevision: next.checkpoint.revision, attempts: 0, disposition: { kind: 'pending' } };
268
+ await this.putWork(tx, h.lease.scope, work);
269
+ }
270
+ row.checkpoint = next.checkpoint;
271
+ row.last_page = digest;
272
+ await this.save(tx, h.lease.scope, row);
273
+ return row.checkpoint;
274
+ });
275
+ }
276
+ async putWork(tx, scope, work) {
277
+ await tx.run(`INSERT INTO ${this.t.work}(tenant_id,owner_id,connection_id,source_id,work)
278
+ VALUES($1,$2,$3,$4,$5::jsonb) ON CONFLICT(tenant_id,owner_id,connection_id,source_id)
279
+ DO UPDATE SET work=excluded.work`, [...params(scope), work.sourceId, JSON.stringify(work)]);
280
+ }
281
+ /** Null id takes the first due item. Increment before external work; a crash leaves it pending. */
282
+ async takeWork(h, id = null) {
283
+ return this.withHandle(h, async (tx, row) => {
284
+ const rows = await tx.all(`SELECT work FROM ${this.t.work}
285
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3
286
+ AND ($4::text IS NULL OR source_id=$4) ORDER BY source_id`, [...params(h.lease.scope), id]);
287
+ const work = rows.map((r) => r.work).find((w) => w.cycleId === row.checkpoint?.cycleId
288
+ && w.generation === h.lease.generation && (w.disposition.kind === 'pending'
289
+ || (w.disposition.kind === 'retry' && w.disposition.retryAt <= this.now())));
290
+ if (!work)
291
+ return null;
292
+ const attempts = work.disposition.kind === 'retry' && work.attempts >= 5 ? 1 : work.attempts + 1;
293
+ if (!validNumber(attempts))
294
+ throw new SourceSyncFault('invalid_response');
295
+ const next = { ...work, attempts, disposition: { kind: 'pending' } };
296
+ await this.putWork(tx, h.lease.scope, next);
297
+ return next;
298
+ });
299
+ }
300
+ async currentWork(tx, scope, cp, work) {
301
+ const found = await tx.get(`SELECT work FROM ${this.t.work}
302
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=$4`, [...params(scope), work.sourceId]);
303
+ const w = found?.work;
304
+ if (!cp || !w || w.cycleId !== cp.cycleId || w.cycleId !== work.cycleId
305
+ || w.generation !== work.generation || w.discoveredRevision !== work.discoveredRevision
306
+ || w.attempts !== work.attempts || w.disposition.kind !== 'pending')
307
+ throw new StaleSync();
308
+ return w;
309
+ }
310
+ normalized(h, work, doc, policy) {
311
+ const now = this.now();
312
+ const version = doc.read.item.version;
313
+ const text = doc.read.content.text;
314
+ const fail = () => { throw new SourceSyncFault('invalid_response'); };
315
+ if (doc.read.item.id !== work.sourceId || !version || version !== doc.read.ref.version
316
+ || !validId(work.sourceId) || typeof text !== 'string' || text.length === 0 || text.length > 1_048_576
317
+ || typeof doc.contentHash !== 'string' || !doc.contentHash || doc.contentHash.length > 512
318
+ || doc.cache.kind !== 'cache' || !doc.cache.evidenceId || !validNumber(doc.cache.expiresAt)
319
+ || doc.cache.expiresAt <= now || !validNumber(doc.activityAt) || doc.activityAt > now
320
+ || !doc.messages.length || doc.messages.length > 1000)
321
+ fail();
322
+ let end = 0;
323
+ const ids = new Set();
324
+ for (const message of doc.messages) {
325
+ if (!validId(message.messageId) || ids.has(message.messageId) || !validNumber(message.start)
326
+ || !validNumber(message.end) || message.start < end || message.end < message.start || message.end > text.length
327
+ || !validNumber(message.receivedAt) || message.receivedAt > now
328
+ || !validNumber(message.windowExpiresAt) || message.windowExpiresAt > message.receivedAt + policy.windowMs
329
+ || doc.cache.expiresAt > message.windowExpiresAt || typeof message.truncated !== 'boolean')
330
+ fail();
331
+ ids.add(message.messageId);
332
+ end = message.end;
333
+ }
334
+ if (doc.activityAt !== Math.max(...doc.messages.map((m) => m.receivedAt)))
335
+ fail();
336
+ const readAt = Date.parse(doc.read.ref.readAt);
337
+ if (!validNumber(readAt) || readAt > now)
338
+ fail();
339
+ const dependency = { ...h.lease.scope, sourceId: work.sourceId,
340
+ generation: h.lease.generation, messageIds: [...ids], version: version, expiresAt: doc.cache.expiresAt };
341
+ createSourceDerivation([dependency]);
342
+ return { dependency, messages: doc.messages.map((m) => ({ ...m })),
343
+ activityAt: doc.activityAt, characters: text.length, contentHash: doc.contentHash };
344
+ }
345
+ async documentRows(tx, scope) {
346
+ const rows = await tx.all(`SELECT document FROM ${this.t.documents}
347
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3`, params(scope));
348
+ return rows.map((r) => r.document);
349
+ }
350
+ /** Internal provenance enumeration. Never expose this as a browser retrieval response. */
351
+ async documents(scope) {
352
+ return this.documentRows(this.db, scope);
353
+ }
354
+ /** Whether the connection holds an exclusion for this source id. */
355
+ async isExcluded(scope, id) {
356
+ return !!await this.db.get(`SELECT 1 FROM ${this.t.exclusions}
357
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=$4`, [...params(scope), id]);
358
+ }
359
+ choose(rows, policy, maximumDocuments, maximumCharacters, now) {
360
+ const selected = [];
361
+ let held = 0;
362
+ for (const doc of [...rows].sort(newest)) {
363
+ if (doc.dependency.expiresAt <= now || doc.messages.some((m) => m.receivedAt + policy.windowMs <= now))
364
+ continue;
365
+ if (selected.length >= Math.min(policy.maxRetainedDocuments, maximumDocuments))
366
+ break;
367
+ if (held + doc.characters > Math.min(policy.maxHeldCharacters, maximumCharacters))
368
+ continue;
369
+ held += doc.characters;
370
+ selected.push(doc);
371
+ }
372
+ return selected;
373
+ }
374
+ /**
375
+ * Applies one read result for a work item taken in this cycle, under a live lease and the owner fence. A retry
376
+ * keeps the work unresolved. Any other result first purges the source's old copy and every result derived from it;
377
+ * a document then replaces it in the same transaction (host write, indexing charge and provenance) when it is not
378
+ * excluded and fits the retained and indexing limits, evicting this connection's older sources the limits no
379
+ * longer admit. A document that is excluded or does not fit is recorded as a skip with its reason.
380
+ *
381
+ * @param prepared - The host's prepared value for a document result; null for any other result.
382
+ * @returns The disposition and the handle later calls use, whose ticket reflects the purge; null for a stale
383
+ * handle or work item.
384
+ * @throws {SourceSyncFault} `invalid_response` for a result that does not match its work item or is malformed.
385
+ * @throws {Error} `invalid_source_allowance` when the host's allowance is not made of nonnegative safe integers.
386
+ */
387
+ async applyResult(h, work, result, prepared) {
388
+ return this.withHandle(h, async (tx, row) => {
389
+ const s = h.lease.scope;
390
+ await this.currentWork(tx, s, row.checkpoint, work);
391
+ if (result.kind === "skipped" && !skipReasons.includes(result.reason))
392
+ throw new SourceSyncFault("invalid_response");
393
+ if (result.kind !== 'document' && result.id !== work.sourceId)
394
+ throw new SourceSyncFault('invalid_response');
395
+ if (result.kind === 'retry') {
396
+ if (!validNumber(result.retryAt) || result.retryAt <= this.now()
397
+ || !['network', 'rate_limited', 'provider_unavailable', 'storage_unavailable'].includes(result.reason)) {
398
+ throw new SourceSyncFault('invalid_response');
399
+ }
400
+ const disposition = { kind: 'retry', reason: result.reason, retryAt: result.retryAt };
401
+ await this.putWork(tx, s, { ...work, disposition });
402
+ return { handle: h, disposition };
403
+ }
404
+ const all = await this.documentRows(tx, s);
405
+ const old = all.find((d) => d.dependency.sourceId === work.sourceId);
406
+ let normalized = null;
407
+ let disposition = result.kind === 'gone' ? { kind: 'gone' }
408
+ : result.kind === 'skipped' ? { kind: 'skipped', reason: result.reason }
409
+ : { kind: 'pending' };
410
+ let victims = [];
411
+ const excluded = await tx.get(`SELECT 1 FROM ${this.t.exclusions}
412
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=$4`, [...params(s), work.sourceId]);
413
+ if (result.kind === 'document' && excluded)
414
+ disposition = { kind: 'skipped', reason: 'excluded' };
415
+ else if (result.kind === 'document') {
416
+ normalized = this.normalized(h, work, result.document, row.policy);
417
+ const allowance = await this.options.bindings.allowance(tx, s);
418
+ if (!Object.values(allowance).every(validNumber))
419
+ throw new Error('invalid_source_allowance');
420
+ const maximumDocuments = allowance.maximumConnectionDocuments;
421
+ const maximumCharacters = allowance.maximumConnectionHeldCharacters;
422
+ const selected = this.choose([...all.filter((d) => d !== old), normalized], row.policy, maximumDocuments, maximumCharacters, this.now());
423
+ if (!selected.includes(normalized)) {
424
+ const reason = selected.length >= Math.min(row.policy.maxRetainedDocuments, maximumDocuments)
425
+ ? 'document_limit' : 'held_limit';
426
+ disposition = { kind: 'skipped', reason };
427
+ normalized = null;
428
+ }
429
+ else if (normalized.characters > Math.min(row.policy.maxIndexedCharacters, allowance.additionalIndexedCharacters)) {
430
+ disposition = { kind: 'skipped', reason: 'indexing_limit' };
431
+ normalized = null;
432
+ }
433
+ else {
434
+ if (prepared === null)
435
+ throw new SourceSyncFault('invalid_response');
436
+ victims = all.filter((d) => d !== old && !selected.includes(d)).map((d) => d.dependency.sourceId);
437
+ disposition = { kind: 'applied', version: normalized.dependency.version };
438
+ }
439
+ }
440
+ let ticket = h.ticket;
441
+ const localFence = this.fenceOn(tx);
442
+ for (const sourceId of [...new Set([...victims, work.sourceId])].sort()) {
443
+ const invalidated = await localFence.invalidate(s, { kind: 'source', connectionId: s.connectionId, sourceId }, async () => { }, { expected: ticket });
444
+ if (invalidated.kind !== 'applied')
445
+ throw new StaleSync();
446
+ ticket = invalidated.ticket;
447
+ }
448
+ if (normalized && result.kind === 'document' && prepared !== null) {
449
+ await this.options.bindings.write(tx, s, normalized.dependency, result.document, prepared);
450
+ await this.options.bindings.chargeIndexed(tx, s, normalized.characters);
451
+ await tx.run(`INSERT INTO ${this.t.documents}(tenant_id,owner_id,connection_id,source_id,document,expires_at)
452
+ VALUES($1,$2,$3,$4,$5::jsonb,$6)`, [...params(s), work.sourceId,
453
+ JSON.stringify(normalized), normalized.dependency.expiresAt]);
454
+ }
455
+ // A slow binding cannot extend permission or finish after its lease expired.
456
+ if (normalized && normalized.dependency.expiresAt <= this.now())
457
+ throw new StaleSync();
458
+ await this.checked(tx, h);
459
+ await this.putWork(tx, s, { ...work, disposition });
460
+ return { handle: { ...h, ticket }, disposition };
461
+ });
462
+ }
463
+ async summary(tx, scope, cp) {
464
+ const rows = await tx.all(`SELECT work FROM ${this.t.work}
465
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3`, params(scope));
466
+ const out = { cycleId: cp.cycleId, generation: cp.generation, checkpointRevision: cp.revision,
467
+ total: 0, pending: 0, retry: 0, applied: 0, gone: 0, skipped: 0 };
468
+ for (const { work } of rows) {
469
+ if (work.cycleId !== cp.cycleId || work.generation !== cp.generation)
470
+ throw new StaleSync();
471
+ out.total++;
472
+ out[work.disposition.kind]++;
473
+ }
474
+ return out;
475
+ }
476
+ /**
477
+ * Records the cycle's progress, counted from its stored work and the retained documents.
478
+ *
479
+ * @returns The state, or null for a stale handle.
480
+ */
481
+ async progress(h) {
482
+ return this.withHandle(h, async (tx, row) => {
483
+ if (!row.checkpoint)
484
+ return row.state;
485
+ const summary = await this.summary(tx, h.lease.scope, row.checkpoint);
486
+ const documents = await this.documentRows(tx, h.lease.scope);
487
+ row.state = transitionSourceSync(row.state, { type: 'sync_progress', progress: {
488
+ examined: summary.total - summary.pending - summary.retry, committed: documents.length,
489
+ skipped: summary.skipped, bounded: row.checkpoint.bounded,
490
+ } }, this.now());
491
+ await this.save(tx, h.lease.scope, row);
492
+ return row.state;
493
+ });
494
+ }
495
+ /**
496
+ * Commits the cycle's target cursor once every work item is terminal, counting from the stored work and never from
497
+ * a caller's numbers, then clears the cycle, releases the lease and schedules the next poll.
498
+ *
499
+ * @returns The connected state, or null while work is unresolved or for a stale handle.
500
+ */
501
+ async finish(h, expected) {
502
+ return this.withHandle(h, async (tx, row) => {
503
+ const cp = row.checkpoint;
504
+ if (!cp || cp.cycleId !== expected.cycleId || cp.revision !== expected.revision)
505
+ throw new StaleSync();
506
+ const summary = await this.summary(tx, h.lease.scope, cp);
507
+ const cursor = committableSourceCursor(cp, summary);
508
+ if (cursor === null)
509
+ return null;
510
+ const now = this.now();
511
+ row.state = transitionSourceSync(row.state, { type: 'sync_progress', progress: {
512
+ examined: summary.total, committed: (await this.documentRows(tx, h.lease.scope)).length,
513
+ skipped: summary.skipped, bounded: cp.bounded,
514
+ } }, now);
515
+ row.state = transitionSourceSync(row.state, { type: 'sync_succeeded', cursor, nextSyncAt: now + row.policy.pollMs }, now);
516
+ if (cp.mode !== 'incremental')
517
+ row.last_reconcile = now;
518
+ await this.clearCycle(tx, h.lease.scope, row);
519
+ await this.save(tx, h.lease.scope, row);
520
+ return row.state;
521
+ });
522
+ }
523
+ /**
524
+ * Releases the lease, recording a deferral or a fixed failure when one is given; the cycle's checkpoint and work
525
+ * stay for the next claim.
526
+ *
527
+ * @returns Whether the lease was released.
528
+ */
529
+ async release(h, event = null) {
530
+ const result = await this.withHandle(h, async (tx, row) => {
531
+ if (event)
532
+ row.state = transitionSourceSync(row.state, event, this.now());
533
+ row.lease_token = null;
534
+ row.lease_until = null;
535
+ await this.save(tx, h.lease.scope, row);
536
+ return true;
537
+ });
538
+ return result === true;
539
+ }
540
+ /**
541
+ * The current state of each of the derivation's retained sources, read in the given transaction for the fence's
542
+ * commit and delivery checks; not an authorization answer of its own. An owner the host's `active` binding refuses
543
+ * has no sources, and a connection with a barrier or pending revalidation reads as disconnected.
544
+ */
545
+ async readCurrent(tx, derivation) {
546
+ if (!await this.options.bindings.active(tx, derivation))
547
+ return [];
548
+ const states = [];
549
+ for (const dependency of derivation.dependencies) {
550
+ const row = await tx.get(`SELECT d.document,c.state,c.barrier FROM ${this.t.documents} d JOIN ${this.t.connections} c
551
+ USING(tenant_id,owner_id,connection_id) WHERE d.tenant_id=$1 AND d.owner_id=$2
552
+ AND d.connection_id=$3 AND d.source_id=$4`, [...params(dependency), dependency.sourceId]);
553
+ if (row)
554
+ states.push({ ...row.document.dependency,
555
+ connected: row.barrier === null && !row.state.revalidationRequired
556
+ && ['connected', 'syncing'].includes(row.state.status),
557
+ paused: row.state.paused, ...row.state.use });
558
+ }
559
+ return states;
560
+ }
561
+ /** Removes the host's content and this store's provenance in the affected scope; the fence calls it inside an invalidation. */
562
+ async purgeSources(tx, scope, affected) {
563
+ await this.options.bindings.purge(tx, scope, affected);
564
+ const args = [scope.tenantId, scope.ownerId, affected.kind === 'owner' ? null : affected.connectionId,
565
+ affected.kind === 'source' ? affected.sourceId : null];
566
+ await tx.run(`DELETE FROM ${this.t.documents} WHERE tenant_id=$1 AND owner_id=$2
567
+ AND ($3::text IS NULL OR connection_id=$3) AND ($4::text IS NULL OR source_id=$4)`, args);
568
+ }
569
+ /**
570
+ * Applies a host control: completes any earlier barrier, then blocks the owner, moves the connection, records a
571
+ * barrier and drops the open cycle in one transaction, and completes the barrier before reactivating the owner.
572
+ * Connecting, grant loss and disconnecting purge the connection and its exclusions; a policy change, which takes
573
+ * the new policy, prunes retained sources to it and shortens their expiry to its window; the other controls rebind
574
+ * content to the new generation without changing its version or expiry.
575
+ *
576
+ * @returns The snapshot after the barrier is complete.
577
+ * @throws {Error} `source_policy_control_required` unless a policy comes exactly with `policy_changed`, and
578
+ * `stale_source_sync` when the host's `active` binding refuses anything but disconnecting or a barrier is still set.
579
+ */
580
+ async control(scope, event, policy) {
581
+ if (policy)
582
+ assertSourceSyncPolicy(policy);
583
+ if ((event.type === 'policy_changed') !== (policy !== undefined))
584
+ throw new Error('source_policy_control_required');
585
+ await this.recoverControl(scope);
586
+ await this.fence.blockOwner(scope, async (tx) => {
587
+ if (!await this.options.bindings.active(tx, scope) && event.type !== 'disconnect')
588
+ throw new StaleSync();
589
+ const row = await this.row(tx, scope);
590
+ if (row.barrier)
591
+ throw new StaleSync();
592
+ row.state = transitionSourceSync(row.state, event, this.now());
593
+ if (policy)
594
+ row.policy = policy;
595
+ const action = ['connect_started', 'grant_lost', 'disconnect'].includes(event.type) ? 'purge'
596
+ : event.type === 'policy_changed' ? 'prune' : 'rebind';
597
+ row.barrier = { token: randomUUID(), action, sourceId: null };
598
+ await this.clearCycle(tx, scope, row);
599
+ if (action === 'rebind')
600
+ await this.rebind(tx, scope, row.state.generation);
601
+ await this.save(tx, scope, row);
602
+ });
603
+ await this.recoverControl(scope);
604
+ return this.snapshot(scope);
605
+ }
606
+ async rebind(tx, scope, generation) {
607
+ await this.options.bindings.rebind(tx, scope, generation);
608
+ for (const doc of await this.documentRows(tx, scope)) {
609
+ const next = { ...doc, dependency: { ...doc.dependency, generation } };
610
+ await tx.run(`UPDATE ${this.t.documents} SET document=$5::jsonb
611
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=$4`, [...params(scope), doc.dependency.sourceId, JSON.stringify(next)]);
612
+ }
613
+ }
614
+ /** Called by the lifecycle worker even when a connection is paused or the owner is blocked. */
615
+ async recoverControl(scope) {
616
+ const before = await this.snapshot(scope);
617
+ if (!before?.barrier) {
618
+ await this.activate(scope);
619
+ return;
620
+ }
621
+ const barrier = before.barrier;
622
+ // Re-enter the committed inactive owner fence; concurrent recovery is idempotent.
623
+ await this.fence.blockOwner(scope, async (tx) => {
624
+ const row = await this.row(tx, scope);
625
+ if (!row.barrier || row.barrier.token !== barrier.token)
626
+ return;
627
+ const localFence = this.fenceOn(tx);
628
+ const remove = async (sourceId) => {
629
+ await localFence.invalidate(scope, sourceId === undefined
630
+ ? { kind: 'connection', connectionId: scope.connectionId }
631
+ : { kind: 'source', connectionId: scope.connectionId, sourceId }, async () => { });
632
+ };
633
+ if (barrier.action === 'purge') {
634
+ await remove();
635
+ await tx.run(`DELETE FROM ${this.t.exclusions} WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3`, params(scope));
636
+ }
637
+ else if (barrier.action === 'exclude')
638
+ await remove(barrier.sourceId);
639
+ else if (barrier.action === 'prune') {
640
+ const docs = await this.documentRows(tx, scope);
641
+ const allowance = await this.options.bindings.allowance(tx, scope);
642
+ if (!Object.values(allowance).every(validNumber))
643
+ throw new Error('invalid_source_allowance');
644
+ const kept = this.choose(docs, row.policy, allowance.maximumConnectionDocuments, allowance.maximumConnectionHeldCharacters, this.now());
645
+ for (const doc of docs)
646
+ if (!kept.includes(doc))
647
+ await remove(doc.dependency.sourceId);
648
+ await this.rebind(tx, scope, row.state.generation);
649
+ for (const doc of await this.documentRows(tx, scope)) {
650
+ const messages = doc.messages.map((m) => ({ ...m,
651
+ windowExpiresAt: Math.min(m.windowExpiresAt, m.receivedAt + row.policy.windowMs) }));
652
+ const expiresAt = Math.min(doc.dependency.expiresAt, ...messages.map((m) => m.windowExpiresAt));
653
+ const next = { ...doc, messages,
654
+ dependency: { ...doc.dependency, expiresAt } };
655
+ await tx.run(`UPDATE ${this.t.documents} SET document=$5::jsonb,expires_at=$6
656
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=$4`, [...params(scope), doc.dependency.sourceId, JSON.stringify(next), expiresAt]);
657
+ }
658
+ }
659
+ row.barrier = null;
660
+ await this.save(tx, scope, row);
661
+ });
662
+ await this.activate(scope);
663
+ }
664
+ /**
665
+ * Removes one source and every result derived from it, and keeps a content-free exclusion so later syncs skip it.
666
+ * The connection moves to a new generation, its cursor is cleared and its open cycle is dropped.
667
+ *
668
+ * @throws {SourceSyncFault} `invalid_response` for an empty id or one longer than 512 characters.
669
+ */
670
+ async exclude(scope, sourceId) {
671
+ if (!validId(sourceId))
672
+ throw new SourceSyncFault('invalid_response');
673
+ await this.recoverControl(scope);
674
+ await this.fence.blockOwner(scope, async (tx) => {
675
+ if (!await this.options.bindings.active(tx, scope))
676
+ throw new StaleSync();
677
+ const row = await this.row(tx, scope);
678
+ if (row.barrier)
679
+ throw new StaleSync();
680
+ row.state = transitionSourceSync(row.state, { type: 'policy_changed' }, this.now());
681
+ await this.clearCycle(tx, scope, row);
682
+ await tx.run(`INSERT INTO ${this.t.exclusions} VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING`, [...params(scope), sourceId]);
683
+ row.barrier = { token: randomUUID(), action: 'exclude', sourceId };
684
+ await this.rebind(tx, scope, row.state.generation);
685
+ await this.save(tx, scope, row);
686
+ });
687
+ await this.recoverControl(scope);
688
+ }
689
+ /**
690
+ * Clears the connection's exclusions. The connection moves to a new generation, its cursor is cleared and its open
691
+ * cycle is dropped.
692
+ */
693
+ async resetExclusions(scope) {
694
+ await this.recoverControl(scope);
695
+ await this.fence.blockOwner(scope, async (tx) => {
696
+ if (!await this.options.bindings.active(tx, scope))
697
+ throw new StaleSync();
698
+ const row = await this.row(tx, scope);
699
+ if (row.barrier)
700
+ throw new StaleSync();
701
+ row.state = transitionSourceSync(row.state, { type: 'policy_changed' }, this.now());
702
+ await this.clearCycle(tx, scope, row);
703
+ await tx.run(`DELETE FROM ${this.t.exclusions} WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3`, params(scope));
704
+ row.barrier = { token: randomUUID(), action: 'reset', sourceId: null };
705
+ await this.rebind(tx, scope, row.state.generation);
706
+ await this.save(tx, scope, row);
707
+ });
708
+ await this.recoverControl(scope);
709
+ }
710
+ /**
711
+ * Removes retained sources whose expiry has passed, with every result derived from them, also while the connection
712
+ * is paused, then expires the owner's derived results. Sources are removed only while the owner's fence is active;
713
+ * reads refuse an expired source regardless.
714
+ *
715
+ * @param limit - At most this many sources, an integer from 1 to 1,000.
716
+ * @returns How many sources were removed.
717
+ * @throws {Error} `invalid_sync_batch` for another limit.
718
+ */
719
+ async expire(scope, limit = 100) {
720
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
721
+ throw new Error('invalid_sync_batch');
722
+ const rows = await this.db.all(`SELECT source_id FROM ${this.t.documents}
723
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND expires_at <= $4
724
+ ORDER BY expires_at,source_id LIMIT $5`, [...params(scope), this.now(), limit]);
725
+ let removed = 0;
726
+ for (const row of rows) {
727
+ const ticket = await this.fence.capture(scope);
728
+ if (!ticket)
729
+ break;
730
+ try {
731
+ const result = await this.fence.apply(ticket, async (tx) => {
732
+ const current = await tx.get(`SELECT expires_at FROM ${this.t.documents}
733
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=$4`, [...params(scope), row.source_id]);
734
+ if (!current || Number(current.expires_at) > this.now())
735
+ return false;
736
+ await this.fenceOn(tx).invalidate(scope, { kind: 'source', connectionId: scope.connectionId, sourceId: row.source_id }, async () => { }, { expected: ticket });
737
+ return true;
738
+ });
739
+ if (result.kind === 'applied' && result.value)
740
+ removed++;
741
+ }
742
+ catch (error) {
743
+ if (!(error instanceof StaleSync))
744
+ throw error;
745
+ }
746
+ }
747
+ await this.fence.expireResults(scope, limit);
748
+ return removed;
749
+ }
750
+ }
751
+ //# sourceMappingURL=SqlAccountSourceSyncStore.js.map