@agentpulselabs/cli-plugin-pulse 1.1.3 → 1.1.6

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.
@@ -91,6 +91,8 @@ export class RunnerStore {
91
91
  }
92
92
  }
93
93
 
94
+ #statements = new Map();
95
+
94
96
  constructor({ db, file, now, maxPending }) {
95
97
  this.db = db;
96
98
  this.file = file;
@@ -103,20 +105,30 @@ export class RunnerStore {
103
105
  this.db.close();
104
106
  }
105
107
 
108
+ /** The statement for this SQL, prepared once per connection: the scanners call the store once or more per row. */
109
+ sql(text) {
110
+ let statement = this.#statements.get(text);
111
+ if (!statement) {
112
+ statement = this.db.prepare(text);
113
+ this.#statements.set(text, statement);
114
+ }
115
+ return statement;
116
+ }
117
+
106
118
  /**
107
119
  * Run `fn` in one immediate transaction: everything it enqueues and every cursor it moves commit together. Inside a
108
120
  * transaction already open on this connection (the runner's capture) it joins that one.
109
121
  */
110
122
  transaction(fn) {
111
123
  if (this.depth > 0 || this.db.isTransaction) return fn();
112
- this.db.prepare('BEGIN IMMEDIATE').run();
124
+ this.sql('BEGIN IMMEDIATE').run();
113
125
  this.depth += 1;
114
126
  try {
115
127
  const out = fn();
116
- this.db.prepare('COMMIT').run();
128
+ this.sql('COMMIT').run();
117
129
  return out;
118
130
  } catch (error) {
119
- this.db.prepare('ROLLBACK').run();
131
+ this.sql('ROLLBACK').run();
120
132
  throw error;
121
133
  } finally {
122
134
  this.depth -= 1;
@@ -124,34 +136,34 @@ export class RunnerStore {
124
136
  }
125
137
 
126
138
  meta(key) {
127
- const row = this.db.prepare('SELECT value FROM meta WHERE key = ?').get(key);
139
+ const row = this.sql('SELECT value FROM meta WHERE key = ?').get(key);
128
140
  return row ? JSON.parse(row.value) : null;
129
141
  }
130
142
 
131
143
  setMeta(key, value) {
132
- this.db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value')
144
+ this.sql('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value')
133
145
  .run(key, JSON.stringify(value));
134
146
  }
135
147
 
136
148
  cursor(source) {
137
- const row = this.db.prepare('SELECT position FROM cursor WHERE source = ?').get(source);
149
+ const row = this.sql('SELECT position FROM cursor WHERE source = ?').get(source);
138
150
  return row ? JSON.parse(row.position) : null;
139
151
  }
140
152
 
141
153
  setCursor(source, position) {
142
- this.db.prepare('INSERT INTO cursor (source, position) VALUES (?, ?) ON CONFLICT (source) DO UPDATE SET position = excluded.position')
154
+ this.sql('INSERT INTO cursor (source, position) VALUES (?, ?) ON CONFLICT (source) DO UPDATE SET position = excluded.position')
143
155
  .run(source, JSON.stringify(position));
144
156
  }
145
157
 
146
158
  /** Whether a key was ever enqueued: pending, acknowledged or dead. */
147
159
  known(key) {
148
- return Boolean(this.db.prepare(
160
+ return Boolean(this.sql(
149
161
  'SELECT 1 AS found FROM outbox WHERE key = ? UNION ALL SELECT 1 FROM sent WHERE key = ? UNION ALL SELECT 1 FROM dead WHERE key = ? LIMIT 1',
150
162
  ).get(key, key, key));
151
163
  }
152
164
 
153
165
  pendingCount() {
154
- return Number(this.db.prepare('SELECT COUNT(*) AS n FROM outbox').get().n);
166
+ return Number(this.sql('SELECT COUNT(*) AS n FROM outbox').get().n);
155
167
  }
156
168
 
157
169
  /**
@@ -160,7 +172,7 @@ export class RunnerStore {
160
172
  */
161
173
  enqueue(entries) {
162
174
  return this.transaction(() => {
163
- const insert = this.db.prepare(
175
+ const insert = this.sql(
164
176
  `INSERT INTO outbox (key, grp, type, payload, source, enqueued_at)
165
177
  SELECT ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM sent WHERE key = ?) AND NOT EXISTS (SELECT 1 FROM dead WHERE key = ?)
166
178
  ON CONFLICT (key) DO NOTHING`,
@@ -214,40 +226,40 @@ export class RunnerStore {
214
226
  /** The cloud stored (or already had) this key. */
215
227
  acked(key, { seq = null, duplicate = false } = {}) {
216
228
  this.transaction(() => {
217
- const row = this.db.prepare('SELECT source, type FROM outbox WHERE key = ?').get(key);
229
+ const row = this.sql('SELECT source, type FROM outbox WHERE key = ?').get(key);
218
230
  if (!row) return;
219
- this.db.prepare('INSERT INTO sent (key, source, type, seq, duplicate, at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (key) DO NOTHING')
231
+ this.sql('INSERT INTO sent (key, source, type, seq, duplicate, at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (key) DO NOTHING')
220
232
  .run(key, row.source, row.type, seq === null ? null : String(seq), duplicate ? 1 : 0, this.now());
221
- this.db.prepare('DELETE FROM outbox WHERE key = ?').run(key);
233
+ this.sql('DELETE FROM outbox WHERE key = ?').run(key);
222
234
  });
223
235
  }
224
236
 
225
237
  /** A retryable refusal: the row stays first in its group and is due again after `delayMs`. */
226
238
  retry(key, { code, delayMs }) {
227
- this.db.prepare('UPDATE outbox SET attempts = attempts + 1, next_at = ?, last_error = ? WHERE key = ?').run(this.now() + delayMs, code, key);
239
+ this.sql('UPDATE outbox SET attempts = attempts + 1, next_at = ?, last_error = ? WHERE key = ?').run(this.now() + delayMs, code, key);
228
240
  }
229
241
 
230
242
  /** Held behind an earlier command of its group: due again after `delayMs`, without counting an attempt. */
231
243
  hold(key, { code, delayMs }) {
232
- this.db.prepare('UPDATE outbox SET next_at = ?, last_error = ? WHERE key = ?').run(this.now() + delayMs, code, key);
244
+ this.sql('UPDATE outbox SET next_at = ?, last_error = ? WHERE key = ?').run(this.now() + delayMs, code, key);
233
245
  }
234
246
 
235
247
  /** A refusal that repeating cannot fix: the row leaves the outbox with its reason. */
236
248
  dead(key, { code, message = null }) {
237
249
  this.transaction(() => {
238
- const row = this.db.prepare('SELECT grp, type, payload, source FROM outbox WHERE key = ?').get(key);
250
+ const row = this.sql('SELECT grp, type, payload, source FROM outbox WHERE key = ?').get(key);
239
251
  if (!row) return;
240
- this.db.prepare(
252
+ this.sql(
241
253
  `INSERT INTO dead (key, grp, type, payload, source, code, message, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
242
254
  ON CONFLICT (key) DO UPDATE SET code = excluded.code, message = excluded.message, at = excluded.at`,
243
255
  ).run(key, row.grp, row.type, row.payload, row.source, code, message, this.now());
244
- this.db.prepare('DELETE FROM outbox WHERE key = ?').run(key);
256
+ this.sql('DELETE FROM outbox WHERE key = ?').run(key);
245
257
  });
246
258
  }
247
259
 
248
260
  /** A command refused before it was queued (it never reaches the cloud), recorded with its reason. */
249
261
  refuse(entry, { code, message = null }) {
250
- this.db.prepare(
262
+ this.sql(
251
263
  `INSERT INTO dead (key, grp, type, payload, source, code, message, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
252
264
  ON CONFLICT (key) DO NOTHING`,
253
265
  ).run(entry.key, entry.group, entry.type, JSON.stringify(entry.payload ?? null), entry.source, code, message, this.now());
@@ -255,38 +267,38 @@ export class RunnerStore {
255
267
 
256
268
  /** The code a key was refused or dead-lettered with, or null. */
257
269
  deadCode(key) {
258
- return this.db.prepare('SELECT code FROM dead WHERE key = ?').get(key)?.code ?? null;
270
+ return this.sql('SELECT code FROM dead WHERE key = ?').get(key)?.code ?? null;
259
271
  }
260
272
 
261
273
  /** Every queued command in queue order, payload parsed (a reconcile reads a scratch store's whole scan this way). */
262
274
  planned() {
263
- return this.db.prepare('SELECT key, grp, type, payload, source FROM outbox ORDER BY ord').all()
275
+ return this.sql('SELECT key, grp, type, payload, source FROM outbox ORDER BY ord').all()
264
276
  .map((row) => ({ key: row.key, group: row.grp, type: row.type, payload: JSON.parse(row.payload), source: row.source }));
265
277
  }
266
278
 
267
279
  /** Every set-aside key with its code (all of them; deadRows is the newest few). */
268
280
  deadKeys() {
269
- return this.db.prepare('SELECT key, type, source, code FROM dead ORDER BY key').all().map((row) => ({ ...row }));
281
+ return this.sql('SELECT key, type, source, code FROM dead ORDER BY key').all().map((row) => ({ ...row }));
270
282
  }
271
283
 
272
284
  deadRows({ limit = 20 } = {}) {
273
- return this.db.prepare('SELECT key, type, source, code, message, at FROM dead ORDER BY at DESC LIMIT ?').all(limit).map((row) => ({ ...row }));
285
+ return this.sql('SELECT key, type, source, code, message, at FROM dead ORDER BY at DESC LIMIT ?').all(limit).map((row) => ({ ...row }));
274
286
  }
275
287
 
276
288
  /** Counts per source: pending, sent (written or duplicate) and dead (by code). */
277
289
  counts() {
278
290
  const out = {};
279
291
  const at = (source) => (out[source] ||= { pending: 0, written: 0, duplicate: 0, dead: {} });
280
- for (const row of this.db.prepare('SELECT source, COUNT(*) AS n FROM outbox GROUP BY source').all()) at(row.source).pending = Number(row.n);
281
- for (const row of this.db.prepare('SELECT source, duplicate, COUNT(*) AS n FROM sent GROUP BY source, duplicate').all()) {
292
+ for (const row of this.sql('SELECT source, COUNT(*) AS n FROM outbox GROUP BY source').all()) at(row.source).pending = Number(row.n);
293
+ for (const row of this.sql('SELECT source, duplicate, COUNT(*) AS n FROM sent GROUP BY source, duplicate').all()) {
282
294
  at(row.source)[row.duplicate ? 'duplicate' : 'written'] = Number(row.n);
283
295
  }
284
- for (const row of this.db.prepare('SELECT source, code, COUNT(*) AS n FROM dead GROUP BY source, code').all()) at(row.source).dead[row.code] = Number(row.n);
296
+ for (const row of this.sql('SELECT source, code, COUNT(*) AS n FROM dead GROUP BY source, code').all()) at(row.source).dead[row.code] = Number(row.n);
285
297
  return out;
286
298
  }
287
299
 
288
300
  version(entity) {
289
- const row = this.db.prepare('SELECT sha, raw_sha, static_sha, key, at FROM version WHERE entity = ?').get(entity);
301
+ const row = this.sql('SELECT sha, raw_sha, static_sha, key, at FROM version WHERE entity = ?').get(entity);
290
302
  return row ? { sha: row.sha, rawSha: row.raw_sha, staticSha: row.static_sha, key: row.key, at: Number(row.at) } : null;
291
303
  }
292
304
 
@@ -295,7 +307,7 @@ export class RunnerStore {
295
307
  * `<prefix><sep><sha>.<k>` when it returns after another version (A→B→A), so the return is a new event that applies.
296
308
  */
297
309
  versionKey(entity, prefix, sha, separator = ':') {
298
- const seen = this.db.prepare('SELECT count FROM version_seen WHERE entity = ? AND sha = ?').get(entity, sha);
310
+ const seen = this.sql('SELECT count FROM version_seen WHERE entity = ? AND sha = ?').get(entity, sha);
299
311
  const count = seen ? Number(seen.count) : 0;
300
312
  return count === 0 ? `${prefix}${separator}${sha}` : `${prefix}${separator}${sha}.${count}`;
301
313
  }
@@ -304,12 +316,12 @@ export class RunnerStore {
304
316
  setVersion(entity, { sha, rawSha = null, staticSha = null, key }) {
305
317
  const last = this.version(entity);
306
318
  if (last?.sha !== sha) {
307
- this.db.prepare(
319
+ this.sql(
308
320
  `INSERT INTO version_seen (entity, sha, count) VALUES (?, ?, 1)
309
321
  ON CONFLICT (entity, sha) DO UPDATE SET count = count + 1`,
310
322
  ).run(entity, sha);
311
323
  }
312
- this.db.prepare(
324
+ this.sql(
313
325
  `INSERT INTO version (entity, sha, raw_sha, static_sha, key, at) VALUES (?, ?, ?, ?, ?, ?)
314
326
  ON CONFLICT (entity) DO UPDATE SET sha = excluded.sha, raw_sha = excluded.raw_sha, static_sha = excluded.static_sha,
315
327
  key = excluded.key, at = excluded.at`,
@@ -318,67 +330,67 @@ export class RunnerStore {
318
330
 
319
331
  /** The raw form last read, without changing what was sent (an unchanged row skips redaction next time). */
320
332
  touchVersion(entity, rawSha) {
321
- this.db.prepare('UPDATE version SET raw_sha = ? WHERE entity = ?').run(rawSha, entity);
333
+ this.sql('UPDATE version SET raw_sha = ? WHERE entity = ?').run(rawSha, entity);
322
334
  }
323
335
 
324
336
  dropVersion(entity) {
325
- this.db.prepare('DELETE FROM version WHERE entity = ?').run(entity);
337
+ this.sql('DELETE FROM version WHERE entity = ?').run(entity);
326
338
  }
327
339
 
328
340
  entities(prefix) {
329
- return this.db.prepare("SELECT entity FROM version WHERE entity >= ? AND entity < ? || char(1114111)").all(prefix, prefix).map((row) => row.entity);
341
+ return this.sql("SELECT entity FROM version WHERE entity >= ? AND entity < ? || char(1114111)").all(prefix, prefix).map((row) => row.entity);
330
342
  }
331
343
 
332
344
  /** The receipt state last queued for each recipient row still able to change (not acked or expired). */
333
345
  openReceipts() {
334
- return this.db.prepare("SELECT message_id, recipient, state FROM receipt_state WHERE state NOT IN ('acked', 'expired')").all()
346
+ return this.sql("SELECT message_id, recipient, state FROM receipt_state WHERE state NOT IN ('acked', 'expired')").all()
335
347
  .map((row) => ({ messageId: row.message_id, recipient: row.recipient, state: row.state }));
336
348
  }
337
349
 
338
350
  receiptState(messageId, recipient) {
339
- return this.db.prepare('SELECT state FROM receipt_state WHERE message_id = ? AND recipient = ?').get(messageId, recipient)?.state ?? null;
351
+ return this.sql('SELECT state FROM receipt_state WHERE message_id = ? AND recipient = ?').get(messageId, recipient)?.state ?? null;
340
352
  }
341
353
 
342
354
  setReceiptState(messageId, recipient, state) {
343
- this.db.prepare(
355
+ this.sql(
344
356
  `INSERT INTO receipt_state (message_id, recipient, state) VALUES (?, ?, ?)
345
357
  ON CONFLICT (message_id, recipient) DO UPDATE SET state = excluded.state`,
346
358
  ).run(messageId, recipient, state);
347
359
  }
348
360
 
349
361
  dropReceipts(messageId) {
350
- this.db.prepare('DELETE FROM receipt_state WHERE message_id = ?').run(messageId);
362
+ this.sql('DELETE FROM receipt_state WHERE message_id = ?').run(messageId);
351
363
  }
352
364
 
353
365
  rememberMessage(id, createdAt, group) {
354
- this.db.prepare('INSERT INTO recent_message (id, created_at, grp) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING').run(id, createdAt, group);
366
+ this.sql('INSERT INTO recent_message (id, created_at, grp) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING').run(id, createdAt, group);
355
367
  }
356
368
 
357
369
  recentMessages(sinceMs) {
358
- return this.db.prepare('SELECT id, created_at, grp FROM recent_message WHERE created_at >= ? ORDER BY created_at').all(sinceMs)
370
+ return this.sql('SELECT id, created_at, grp FROM recent_message WHERE created_at >= ? ORDER BY created_at').all(sinceMs)
359
371
  .map((row) => ({ id: row.id, createdAt: Number(row.created_at), group: row.grp }));
360
372
  }
361
373
 
362
374
  forgetMessage(id) {
363
- this.db.prepare('DELETE FROM recent_message WHERE id = ?').run(id);
375
+ this.sql('DELETE FROM recent_message WHERE id = ?').run(id);
364
376
  }
365
377
 
366
378
  pruneRecentMessages(beforeMs) {
367
- this.db.prepare('DELETE FROM recent_message WHERE created_at < ?').run(beforeMs);
379
+ this.sql('DELETE FROM recent_message WHERE created_at < ?').run(beforeMs);
368
380
  }
369
381
 
370
382
  ingested(channel) {
371
- return this.db.prepare('SELECT ordinal, sha, post_key, at FROM ingested WHERE channel = ? ORDER BY ordinal').all(channel)
383
+ return this.sql('SELECT ordinal, sha, post_key, at FROM ingested WHERE channel = ? ORDER BY ordinal').all(channel)
372
384
  .map((row) => ({ ordinal: Number(row.ordinal), sha: row.sha, postKey: row.post_key, at: Number(row.at) }));
373
385
  }
374
386
 
375
387
  /** The key a channel's block (its digest and occurrence) was first sent under, or null. */
376
388
  ingestedKey(channel, sha) {
377
- return this.db.prepare('SELECT post_key FROM ingested WHERE channel = ? AND sha = ?').get(channel, sha)?.post_key ?? null;
389
+ return this.sql('SELECT post_key FROM ingested WHERE channel = ? AND sha = ?').get(channel, sha)?.post_key ?? null;
378
390
  }
379
391
 
380
392
  setIngested(channel, { ordinal, sha, postKey, at }) {
381
- this.db.prepare(
393
+ this.sql(
382
394
  `INSERT INTO ingested (channel, ordinal, sha, post_key, at) VALUES (?, ?, ?, ?, ?)
383
395
  ON CONFLICT (channel, ordinal) DO UPDATE SET sha = excluded.sha, post_key = excluded.post_key, at = excluded.at`,
384
396
  ).run(channel, ordinal, sha, postKey, at);
@@ -387,18 +399,18 @@ export class RunnerStore {
387
399
  /** What one channel file holds now, by position, replacing what was recorded before. */
388
400
  replaceIngested(channel, rows) {
389
401
  this.transaction(() => {
390
- this.db.prepare('DELETE FROM ingested WHERE channel = ?').run(channel);
402
+ this.sql('DELETE FROM ingested WHERE channel = ?').run(channel);
391
403
  for (const row of rows) this.setIngested(channel, row);
392
404
  });
393
405
  }
394
406
 
395
407
  presenceSent(actorId) {
396
- const row = this.db.prepare('SELECT last_seen, sent_at FROM presence_sent WHERE actor_id = ?').get(actorId);
408
+ const row = this.sql('SELECT last_seen, sent_at FROM presence_sent WHERE actor_id = ?').get(actorId);
397
409
  return row ? { lastSeen: Number(row.last_seen), sentAt: Number(row.sent_at) } : null;
398
410
  }
399
411
 
400
412
  setPresenceSent(actorId, lastSeen) {
401
- this.db.prepare(
413
+ this.sql(
402
414
  `INSERT INTO presence_sent (actor_id, last_seen, sent_at) VALUES (?, ?, ?)
403
415
  ON CONFLICT (actor_id) DO UPDATE SET last_seen = excluded.last_seen, sent_at = excluded.sent_at`,
404
416
  ).run(actorId, lastSeen, this.now());
@@ -406,12 +418,12 @@ export class RunnerStore {
406
418
 
407
419
  /** Whether the cloud acknowledged `key` as a command of `type`. */
408
420
  sentAs(key, type) {
409
- return Boolean(this.db.prepare('SELECT 1 AS found FROM sent WHERE key = ? AND type = ?').get(key, type));
421
+ return Boolean(this.sql('SELECT 1 AS found FROM sent WHERE key = ? AND type = ?').get(key, type));
410
422
  }
411
423
 
412
424
  /** Acknowledged keys older than `beforeMs` go; the cloud's dedupe is the backstop for anything resent after that. */
413
425
  pruneSent(beforeMs) {
414
- return Number(this.db.prepare('DELETE FROM sent WHERE at < ?').run(beforeMs).changes);
426
+ return Number(this.sql('DELETE FROM sent WHERE at < ?').run(beforeMs).changes);
415
427
  }
416
428
  }
417
429
 
@@ -24,6 +24,8 @@ const PRESENCE_CURSOR = 'presence_cursor';
24
24
  export const SYNC_FAILING_AFTER = 3;
25
25
  /** SCOPE-02: while blocked for a scope or grant, the sync is retried this often with the sign-in read again, so a re-login clears it. */
26
26
  export const BLOCK_RETRY_MS = 60_000;
27
+ /** Changes capture refused to send because they hold secret-like content (hubScan): set aside by design, not a fault. */
28
+ const WITHHELD_CODE = 'content_withheld';
27
29
  const ERROR_CODES = new Set(['HUB_LOGIN_REQUIRED', 'HUB_SCOPE_DENIED', 'capability_denied', 'cloud_first_off', 'HUB_MACHINE_REVOKED']);
28
30
 
29
31
  /**
@@ -63,6 +65,7 @@ export class RunnerBridge {
63
65
  if (this.running) return;
64
66
  this.running = true;
65
67
  this.runner.on('wrote', () => this.kick());
68
+ this.runner.on('loop_stall', (message) => this.log('loop_stall', message.details || {}));
66
69
  this.syncing = this.#syncLoop();
67
70
  this.flushing = this.#flushLoop();
68
71
  if (this.leaseConfig) this.leasing = this.#leaseLoop();
@@ -254,10 +257,12 @@ export class RunnerBridge {
254
257
  const now = this.now();
255
258
  const pending = this.store.pendingCount();
256
259
  const dead = Number(this.store.db.prepare('SELECT COUNT(*) AS n FROM dead').get().n);
260
+ const withheld = Number(this.store.db.prepare('SELECT COUNT(*) AS n FROM dead WHERE code = ?').get(WITHHELD_CODE).n);
261
+ const setAside = dead - withheld;
257
262
  const quarantined = Number(this.store.db.prepare('SELECT COUNT(*) AS n FROM pulse_quarantine').get()?.n || 0);
258
263
  const applied = this.applied();
259
264
  const lag = Math.max(0, this.state.head - applied);
260
- const outbox = { pending, dead, capacity: OUTBOX_MAX_PENDING };
265
+ const outbox = { pending, dead, withheld, capacity: OUTBOX_MAX_PENDING };
261
266
  const base = { lastSyncAt: this.state.lastSyncAt, applied, head: this.state.head, lag, outbox, quarantined,
262
267
  staleSeconds: this.state.lastSyncAt ? (now - this.state.lastSyncAt) / 1000 : null };
263
268
  const { block } = this.state.error || {};
@@ -276,8 +281,8 @@ export class RunnerBridge {
276
281
  return { ...base, state: 'offline', reason: this.state.lastContactAt ? 'no contact with Pulse' : 'not connected yet' };
277
282
  }
278
283
  if (lag > LAG_EVENTS || (pending > 0 && this.state.flushError)) return { ...base, state: 'catching_up', reason: `${lag} events and ${pending} changes to go` };
279
- if (dead || quarantined || pending > 0.8 * OUTBOX_MAX_PENDING) {
280
- return { ...base, state: 'degraded', reason: `${dead} change${dead === 1 ? '' : 's'} and ${quarantined} event${quarantined === 1 ? '' : 's'} set aside` };
284
+ if (setAside || quarantined || pending > 0.8 * OUTBOX_MAX_PENDING) {
285
+ return { ...base, state: 'degraded', reason: `${setAside} change${setAside === 1 ? '' : 's'} and ${quarantined} event${quarantined === 1 ? '' : 's'} set aside` };
281
286
  }
282
287
  if (!this.state.lastSyncAt || now - this.state.lastSyncAt > ONLINE_MS) return { ...base, state: 'catching_up', reason: 'waiting for a sync' };
283
288
  return { ...base, state: 'online', reason: 'in sync' };
@@ -204,8 +204,18 @@ export function createBusIdentity(hub, { now: clock = Date.now } = {}) {
204
204
  refreshProof: db.prepare("INSERT INTO pulse_proofs (address, at, via, used) VALUES (?, ?, 'listen', 0) ON CONFLICT (address) DO UPDATE SET at = MAX(pulse_proofs.at, excluded.at)"),
205
205
  useProof: db.prepare('UPDATE pulse_proofs SET used = 1 WHERE address = ?'),
206
206
  remote: db.prepare('SELECT * FROM pulse_remote_presence WHERE actor_id = ?'),
207
+ queuedFor: db.prepare(`SELECT 1 AS found FROM deliveries d JOIN messages m ON m.id = d.message_id
208
+ WHERE d.recipient = ? AND d.state = 'queued' AND m.expires_at > ? LIMIT 1`),
209
+ lapsedFor: db.prepare(`SELECT 1 AS found FROM deliveries d JOIN messages m ON m.id = d.message_id
210
+ WHERE d.recipient = ? AND d.state = 'leased' AND d.lease_until < ? AND m.expires_at > ? LIMIT 1`),
207
211
  };
208
212
 
213
+ /**
214
+ * Whether messaging.claim would find anything for one of `addresses` now (its own rows: queued, or leased with a lapsed
215
+ * lease, and not expired), read without the write lock, so an empty `bus listen` poll needs no write transaction.
216
+ */
217
+ const claimable = (addresses, now = clock()) => addresses.some((a) => q.queuedFor.get(a, now) || q.lapsedFor.get(a, now, now));
218
+
209
219
  /**
210
220
  * Every chat the hub already knows to be a session, before its next hook: hook-seen and transcript-found actors, and
211
221
  * presence rows whose last event came from a hook. Without this, right after an upgrade another session could take a
@@ -464,7 +474,7 @@ export function createBusIdentity(hub, { now: clock = Date.now } = {}) {
464
474
  return { registrations: shown.map(({ active: _active, ...e }) => e), groups: [...groups.values()], window: { minutes: windowMs / 60e3, all: !!all } };
465
475
  }
466
476
 
467
- return { addressesOf, inboxFor, sessionOf, aliasesOf, activity, known, suggest, reach, sawHook, proved, listened, bindFromHook, notices, who };
477
+ return { addressesOf, inboxFor, claimable, sessionOf, aliasesOf, activity, known, suggest, reach, sawHook, proved, listened, bindFromHook, notices, who };
468
478
  }
469
479
 
470
480
  /**
@@ -1,3 +1,4 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
1
2
  import { withTx } from '../hub/core/db.mjs';
2
3
 
3
4
  /**
@@ -18,6 +19,17 @@ const SCHEMA = [
18
19
  'CREATE TABLE IF NOT EXISTS pulse_capture (id INTEGER PRIMARY KEY AUTOINCREMENT, tbl TEXT NOT NULL, op TEXT NOT NULL, k1 TEXT NOT NULL, k2 TEXT)',
19
20
  ];
20
21
 
22
+ /** One chunk of swept or pumped work holds the write lock and the event loop about this long, then yields. */
23
+ export const CHUNK_MS = 50;
24
+ /**
25
+ * Between chunks the lock is left free this long (writeInChunks' pause, without blocking the loop); longer while
26
+ * another connection is writing (it committed since the last pause), because its busy handler sleeps up to 100 ms
27
+ * between tries and would otherwise rarely land in a short gap.
28
+ */
29
+ export const CHUNK_PAUSE_MS = 15;
30
+ export const WRITER_PAUSE_MS = 120;
31
+ const JOURNAL_PAGE = 200;
32
+
21
33
  function triggerSql({ table, keys, ops }) {
22
34
  return ops.map((op) => {
23
35
  const row = op === 'DELETE' ? 'OLD' : 'NEW';
@@ -37,30 +49,40 @@ export function installCapture(db) {
37
49
  * Turns every captured hub write into outbox commands inside the transaction that made it.
38
50
  *
39
51
  * `tx(fn, { guard })` runs fn in one write transaction (the ported core's own withTx calls join it), drains the trigger
40
- * journal through `mapping.commands(change, db)` into the outbox, then runs `guard`; a guard that throws rolls the
52
+ * rows fn added through `mapping.commands(change, db)` into the outbox, then runs `guard`; a guard that throws rolls the
41
53
  * hub write and its commands back together. `applying(fn)` writes with the triggers off, for rows that came from the
42
54
  * cloud: other connections never see the flag set, because SQLite serializes writers and the flag is set and reset
43
- * inside one write transaction. `pump()` drains what writes outside any `tx` left in the journal.
55
+ * inside one write transaction. `pump({ budgetMs })` drains what writes on other connections (the collect worker) left
56
+ * in the journal, oldest first, for at most budgetMs per transaction. `chunks(steps)` runs steps in capture
57
+ * transactions of about CHUNK_MS each, leaving the event loop and the lock free between them (`yieldLock`).
44
58
  */
45
59
  export function createCapture({ db, store, mapping, log = () => {} }) {
46
- const journal = db.prepare('SELECT id, tbl, op, k1, k2 FROM pulse_capture ORDER BY id');
47
- const clear = db.prepare('DELETE FROM pulse_capture WHERE id <= ?');
60
+ const journal = db.prepare('SELECT id, tbl, op, k1, k2 FROM pulse_capture WHERE id > ? ORDER BY id LIMIT ?');
61
+ const newest = db.prepare('SELECT COALESCE(MAX(id), 0) AS id FROM pulse_capture');
62
+ const waiting = db.prepare('SELECT EXISTS (SELECT 1 FROM pulse_capture) AS found');
63
+ const clear = db.prepare('DELETE FROM pulse_capture WHERE id > ? AND id <= ?');
48
64
  const setApplying = db.prepare('UPDATE pulse_ctx SET applying = ? WHERE one = 1');
65
+ const dataVersion = db.prepare('PRAGMA data_version');
66
+ let lastVersion = null;
67
+ const batchOf = () => (mapping.batch ? mapping.batch(db) : (change) => mapping.commands(change, db));
49
68
 
50
- function drain() {
51
- const rows = journal.all();
52
- if (!rows.length) return 0;
53
- const latest = new Map();
54
- for (const row of rows) {
55
- const id = `${row.tbl}\0${row.k1}\0${row.k2 ?? ''}`;
56
- latest.delete(id);
57
- latest.set(id, row);
58
- }
69
+ const entityOf = (row) => `${row.tbl}\0${row.k1}\0${row.k2 ?? ''}`;
70
+
71
+ /**
72
+ * Commands for one page of journal rows, each entity once at its latest row in the page, in row order. Stops after
73
+ * the row at which `over()` turns true; returns the id of the last row it is done with and what it queued. Rows
74
+ * before that whose entity comes again later in the page are left to that later row.
75
+ */
76
+ function drainPage(rows, commands, over) {
77
+ const lastAt = new Map(rows.map((row, index) => [entityOf(row), index]));
59
78
  const entries = [];
60
- for (const { tbl, op, k1, k2 } of latest.values()) {
79
+ let done = rows.length - 1;
80
+ for (const [index, row] of rows.entries()) {
81
+ if (lastAt.get(entityOf(row)) !== index) continue;
82
+ const { tbl, op, k1, k2 } = row;
61
83
  const change = { tbl, op, k1, k2 };
62
84
  try {
63
- entries.push(...mapping.commands(change, db));
85
+ entries.push(...commands(change));
64
86
  } catch (error) {
65
87
  // A full outbox refuses the write itself (507 pulse_backlog), so the caller is told.
66
88
  if (error.code === 'pulse_backlog') throw error;
@@ -71,16 +93,36 @@ export function createCapture({ db, store, mapping, log = () => {} }) {
71
93
  );
72
94
  log('capture_failed', { tbl, op, code: error?.code || 'capture_failed' });
73
95
  }
96
+ if (index < rows.length - 1 && over()) { done = index; break; }
74
97
  }
75
98
  if (entries.length) store.enqueue(entries);
76
- clear.run(rows.at(-1).id);
77
- return entries.length;
99
+ return { last: rows[done].id, queued: entries.length, all: done === rows.length - 1 };
100
+ }
101
+
102
+ /** Drains journal rows after `after`, a page at a time, until none are left or `budgetMs` has passed. */
103
+ function drain({ after = 0, budgetMs = Infinity } = {}) {
104
+ const started = performance.now();
105
+ const over = () => performance.now() - started >= budgetMs;
106
+ const commands = batchOf();
107
+ let last = after;
108
+ let queued = 0;
109
+ for (;;) {
110
+ const rows = journal.all(last, JOURNAL_PAGE);
111
+ if (!rows.length) break;
112
+ const page = drainPage(rows, commands, over);
113
+ queued += page.queued;
114
+ last = page.last;
115
+ if (!page.all || rows.length < JOURNAL_PAGE || over()) break;
116
+ }
117
+ if (last > after) clear.run(after, last);
118
+ return queued;
78
119
  }
79
120
 
80
121
  function tx(fn, { guard = null, label = 'capture' } = {}) {
81
122
  return withTx(db, () => {
123
+ const mark = newest.get().id;
82
124
  const out = fn();
83
- drain();
125
+ drain({ after: mark });
84
126
  if (guard) guard();
85
127
  return out;
86
128
  }, label);
@@ -97,7 +139,28 @@ export function createCapture({ db, store, mapping, log = () => {} }) {
97
139
  }, 'apply');
98
140
  }
99
141
 
100
- const pump = () => withTx(db, drain, 'capture pump');
142
+ const pump = ({ budgetMs = Infinity } = {}) => withTx(db, () => drain({ budgetMs }), 'capture pump');
143
+ const pending = () => Boolean(waiting.get().found);
144
+ const yieldLock = () => {
145
+ const version = dataVersion.get().data_version;
146
+ const writing = lastVersion !== null && version !== lastVersion;
147
+ lastVersion = version;
148
+ return sleep(writing ? WRITER_PAUSE_MS : CHUNK_PAUSE_MS);
149
+ };
150
+
151
+ async function chunks(steps, { budgetMs = CHUNK_MS, label = 'capture chunk' } = {}) {
152
+ let i = 0;
153
+ let n = 0;
154
+ while (i < steps.length) {
155
+ const started = performance.now();
156
+ n += 1;
157
+ tx(() => {
158
+ do steps[i++](); while (i < steps.length && performance.now() - started < budgetMs);
159
+ }, { label: `${label} (chunk ${n})` });
160
+ if (i < steps.length) await yieldLock();
161
+ }
162
+ return n;
163
+ }
101
164
 
102
- return { tx, applying, pump, drain };
165
+ return { tx, applying, pump, pending, chunks, yieldLock, drain };
103
166
  }