instar 1.3.501 → 1.3.503

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.
Files changed (93) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +616 -9
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +48 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/BackupManager.d.ts.map +1 -1
  8. package/dist/core/BackupManager.js +7 -0
  9. package/dist/core/BackupManager.js.map +1 -1
  10. package/dist/core/CoherenceJournal.d.ts +1 -1
  11. package/dist/core/CoherenceJournal.d.ts.map +1 -1
  12. package/dist/core/CoherenceJournal.js +5 -1
  13. package/dist/core/CoherenceJournal.js.map +1 -1
  14. package/dist/core/DeliverMessageHandler.d.ts +13 -1
  15. package/dist/core/DeliverMessageHandler.d.ts.map +1 -1
  16. package/dist/core/DeliverMessageHandler.js +7 -0
  17. package/dist/core/DeliverMessageHandler.js.map +1 -1
  18. package/dist/core/MachinePoolRegistry.d.ts +3 -0
  19. package/dist/core/MachinePoolRegistry.d.ts.map +1 -1
  20. package/dist/core/MachinePoolRegistry.js +1 -0
  21. package/dist/core/MachinePoolRegistry.js.map +1 -1
  22. package/dist/core/OwnerSuspectBreaker.d.ts +37 -0
  23. package/dist/core/OwnerSuspectBreaker.d.ts.map +1 -1
  24. package/dist/core/OwnerSuspectBreaker.js +58 -0
  25. package/dist/core/OwnerSuspectBreaker.js.map +1 -1
  26. package/dist/core/OwnershipReconciler.d.ts +102 -0
  27. package/dist/core/OwnershipReconciler.d.ts.map +1 -0
  28. package/dist/core/OwnershipReconciler.js +185 -0
  29. package/dist/core/OwnershipReconciler.js.map +1 -0
  30. package/dist/core/PendingInboundStore.d.ts +310 -0
  31. package/dist/core/PendingInboundStore.d.ts.map +1 -0
  32. package/dist/core/PendingInboundStore.js +715 -0
  33. package/dist/core/PendingInboundStore.js.map +1 -0
  34. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  35. package/dist/core/PostUpdateMigrator.js +13 -0
  36. package/dist/core/PostUpdateMigrator.js.map +1 -1
  37. package/dist/core/QueueDrainLoop.d.ts +247 -0
  38. package/dist/core/QueueDrainLoop.d.ts.map +1 -0
  39. package/dist/core/QueueDrainLoop.js +0 -0
  40. package/dist/core/QueueDrainLoop.js.map +1 -0
  41. package/dist/core/SessionOwnership.d.ts +16 -1
  42. package/dist/core/SessionOwnership.d.ts.map +1 -1
  43. package/dist/core/SessionOwnership.js +14 -0
  44. package/dist/core/SessionOwnership.js.map +1 -1
  45. package/dist/core/SessionRouter.d.ts +36 -2
  46. package/dist/core/SessionRouter.d.ts.map +1 -1
  47. package/dist/core/SessionRouter.js +34 -9
  48. package/dist/core/SessionRouter.js.map +1 -1
  49. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  50. package/dist/core/devGatedFeatures.js +10 -0
  51. package/dist/core/devGatedFeatures.js.map +1 -1
  52. package/dist/core/inboundQueueBootSweep.d.ts +61 -0
  53. package/dist/core/inboundQueueBootSweep.d.ts.map +1 -0
  54. package/dist/core/inboundQueueBootSweep.js +207 -0
  55. package/dist/core/inboundQueueBootSweep.js.map +1 -0
  56. package/dist/core/inboundQueueConfig.d.ts +78 -0
  57. package/dist/core/inboundQueueConfig.d.ts.map +1 -0
  58. package/dist/core/inboundQueueConfig.js +135 -0
  59. package/dist/core/inboundQueueConfig.js.map +1 -0
  60. package/dist/core/types.d.ts +23 -0
  61. package/dist/core/types.d.ts.map +1 -1
  62. package/dist/core/types.js.map +1 -1
  63. package/dist/messaging/TelegramAdapter.d.ts +4 -0
  64. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  65. package/dist/messaging/TelegramAdapter.js +12 -0
  66. package/dist/messaging/TelegramAdapter.js.map +1 -1
  67. package/dist/monitoring/SessionReaper.d.ts +5 -0
  68. package/dist/monitoring/SessionReaper.d.ts.map +1 -1
  69. package/dist/monitoring/SessionReaper.js +22 -2
  70. package/dist/monitoring/SessionReaper.js.map +1 -1
  71. package/dist/monitoring/guardManifest.d.ts.map +1 -1
  72. package/dist/monitoring/guardManifest.js +27 -0
  73. package/dist/monitoring/guardManifest.js.map +1 -1
  74. package/dist/scaffold/templates.d.ts.map +1 -1
  75. package/dist/scaffold/templates.js +6 -0
  76. package/dist/scaffold/templates.js.map +1 -1
  77. package/dist/server/AgentServer.d.ts +2 -0
  78. package/dist/server/AgentServer.d.ts.map +1 -1
  79. package/dist/server/AgentServer.js +1 -0
  80. package/dist/server/AgentServer.js.map +1 -1
  81. package/dist/server/routes.d.ts +4 -0
  82. package/dist/server/routes.d.ts.map +1 -1
  83. package/dist/server/routes.js +37 -1
  84. package/dist/server/routes.js.map +1 -1
  85. package/package.json +1 -1
  86. package/scripts/lint-guard-manifest.js +2 -0
  87. package/src/data/builtin-manifest.json +65 -65
  88. package/src/scaffold/templates.ts +6 -0
  89. package/upgrades/1.3.502.md +75 -0
  90. package/upgrades/1.3.503.md +61 -0
  91. package/upgrades/side-effects/1.3.492.md +20 -0
  92. package/upgrades/side-effects/durable-inbound-message-queue.md +213 -0
  93. package/upgrades/side-effects/multi-machine-seamlessness-ws13.md +122 -0
@@ -0,0 +1,715 @@
1
+ /**
2
+ * PendingInboundStore — durable custody store for inbound messages the router
3
+ * cannot deliver right now (Durable Inbound Message Queue spec §1).
4
+ *
5
+ * Spec: docs/specs/durable-inbound-message-queue.md (CMT-1118).
6
+ *
7
+ * Path: `<stateDir>/state/pending-inbound.<sanitizedAgentId>.sqlite`, 0600.
8
+ * Pattern: PendingRelayStore, with the spec's named deviations:
9
+ * - `synchronous=FULL` — a custody-ack must survive power loss; this store
10
+ * has no sender-side exit-1 backstop, and write volume is
11
+ * human-message-rate.
12
+ * - chmod 0600 happens BEFORE the WAL pragma so the -wal/-shm sidecars
13
+ * inherit the mode.
14
+ * - The DB handle is a #private field — no raw-handle export, every
15
+ * mutation is a named method (spec §1 round-8: the single-writer executor
16
+ * is a CONTRACT, enforced by encapsulation + a unit test, not convention).
17
+ *
18
+ * Sanitization-collision caveat (inherited from PendingRelayStore): two agent
19
+ * ids that sanitize to the same string would share a file. Same blast radius
20
+ * as the relay store — same-machine, same-user; documented, not defended.
21
+ *
22
+ * The store is data + invariants only. Policy (selection, holds, halt, clock
23
+ * discipline) lives in QueueDrainLoop; the store's methods take explicit
24
+ * `nowIso`/`monoMs` so the policy layer owns time.
25
+ */
26
+ import Database from 'better-sqlite3';
27
+ import { registerSqliteHandle } from './SqliteRegistry.js';
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+ const NON_TERMINAL = ['queued', 'claimed'];
31
+ const TERMINAL = ['delivered', 'expired', 'dropped-overflow'];
32
+ // ── Path resolution ───────────────────────────────────────────────────
33
+ export function resolvePendingInboundPath(stateDir, agentId) {
34
+ return path.join(stateDir, 'state', `pending-inbound.${sanitizeAgentId(agentId)}.sqlite`);
35
+ }
36
+ function sanitizeAgentId(id) {
37
+ return id.replace(/[^A-Za-z0-9._-]/g, '_');
38
+ }
39
+ // ── Schema ────────────────────────────────────────────────────────────
40
+ // Schema legality (spec §1 round-3): AUTOINCREMENT only on INTEGER PRIMARY KEY,
41
+ // so enqueue_seq is the PK and UNIQUE(session_key, message_id) is the dedupe
42
+ // key — never-reused seqs guaranteed (plain rowid reuses after deletes).
43
+ const SCHEMA = [
44
+ `CREATE TABLE IF NOT EXISTS entries (
45
+ enqueue_seq INTEGER PRIMARY KEY AUTOINCREMENT,
46
+ session_key TEXT NOT NULL,
47
+ message_id TEXT NOT NULL,
48
+ payload TEXT,
49
+ payload_bytes INTEGER NOT NULL DEFAULT 0,
50
+ sender_envelope TEXT,
51
+ sender_display TEXT,
52
+ topic_metadata TEXT,
53
+ reason TEXT NOT NULL,
54
+ state TEXT NOT NULL,
55
+ terminal_reason TEXT,
56
+ enqueued_at TEXT NOT NULL,
57
+ enqueued_mono REAL,
58
+ boot_session_id TEXT,
59
+ lease_epoch TEXT,
60
+ first_held_at TEXT,
61
+ first_frozen_at TEXT,
62
+ total_frozen_ms INTEGER NOT NULL DEFAULT 0,
63
+ frozen_since TEXT,
64
+ attempts INTEGER NOT NULL DEFAULT 0,
65
+ next_attempt_at TEXT,
66
+ last_error TEXT,
67
+ delivered_unconfirmed INTEGER NOT NULL DEFAULT 0,
68
+ status_history TEXT NOT NULL DEFAULT '[]',
69
+ UNIQUE (session_key, message_id)
70
+ )`,
71
+ `CREATE INDEX IF NOT EXISTS idx_pi_state_next ON entries(state, next_attempt_at)`,
72
+ `CREATE INDEX IF NOT EXISTS idx_pi_session_seq ON entries(session_key, enqueue_seq)`,
73
+ // Receipts — class-tagged, canonical-id-keyed (§3.4). The class column makes
74
+ // "never confused with ingress rows" mechanical: this table holds ONLY
75
+ // injection/remote receipts, in the queue DB (same durability, same 0600).
76
+ `CREATE TABLE IF NOT EXISTS receipts (
77
+ session_key TEXT NOT NULL,
78
+ message_id TEXT NOT NULL,
79
+ class TEXT NOT NULL,
80
+ created_at TEXT NOT NULL,
81
+ injected INTEGER NOT NULL DEFAULT 0,
82
+ reported INTEGER NOT NULL DEFAULT 0,
83
+ PRIMARY KEY (session_key, message_id, class)
84
+ )`,
85
+ `CREATE INDEX IF NOT EXISTS idx_pi_receipt_created ON receipts(created_at)`,
86
+ // Meta — tenure acquisition generation (§3.5), durable counters (§2.4),
87
+ // pause state. Key/value, integers stored as text.
88
+ `CREATE TABLE IF NOT EXISTS meta (
89
+ key TEXT PRIMARY KEY,
90
+ value TEXT NOT NULL
91
+ )`,
92
+ ];
93
+ const STATUS_HISTORY_CAP = 10;
94
+ const LAST_ERROR_CAP = 300;
95
+ // ── Store ─────────────────────────────────────────────────────────────
96
+ export class PendingInboundStore {
97
+ // ES #private — the encapsulation contract (spec §1 round-8): no raw-handle
98
+ // export. A unit test pins that `'db' in store === false` and no method
99
+ // returns the handle.
100
+ #db;
101
+ #path;
102
+ constructor(db, dbPath) {
103
+ this.#db = db;
104
+ this.#path = dbPath;
105
+ registerSqliteHandle(() => {
106
+ try {
107
+ this.#db?.close();
108
+ }
109
+ catch { /* already closed */ }
110
+ });
111
+ }
112
+ /**
113
+ * Open (or create) the store. Throws on an unopenable file — the caller
114
+ * (QueueDrainLoop boot sweep) owns the §5.3 quarantine path; the store
115
+ * never quarantines itself.
116
+ */
117
+ static open(agentId, stateDir) {
118
+ const dbPath = resolvePendingInboundPath(stateDir, agentId);
119
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
120
+ const db = new Database(dbPath);
121
+ // 0600 BEFORE the WAL pragma so -wal/-shm sidecars inherit the mode
122
+ // (spec §1). Best-effort on filesystems without mode bits.
123
+ try {
124
+ fs.chmodSync(dbPath, 0o600);
125
+ }
126
+ catch { /* best-effort */ }
127
+ db.pragma('journal_mode = WAL');
128
+ db.pragma('synchronous = FULL');
129
+ db.pragma('busy_timeout = 5000');
130
+ for (const ddl of SCHEMA)
131
+ db.exec(ddl);
132
+ return new PendingInboundStore(db, dbPath);
133
+ }
134
+ pathOnDisk() {
135
+ return this.#path;
136
+ }
137
+ close() {
138
+ try {
139
+ this.#db.close();
140
+ }
141
+ catch { /* best-effort */ }
142
+ }
143
+ // ── Enqueue (tri-state, one transaction) ────────────────────────────
144
+ /**
145
+ * Take custody of a message. One transaction: existence check + bounds +
146
+ * (possible per-session eviction) + insert (spec §1 Transactions/Bounds).
147
+ *
148
+ * Tri-state semantics (spec §1):
149
+ * - existing row non-terminal or `delivered` → `already-queued` (custody
150
+ * re-affirmed — correct dedupe);
151
+ * - existing row `expired`/`dropped-overflow` → `refused` (the prior
152
+ * instance was loss-reported; re-affirming custody against a row that
153
+ * will never dispatch would be silent loss);
154
+ * - oversize payload → `refused`;
155
+ * - `hardMaxTotal` reached → `refused` (even carve-out appends);
156
+ * - `maxTotal` reached and the session has nothing queued → `refused`
157
+ * (FIRST entries refused; sessions already queued may append);
158
+ * - `maxPerSession` reached → evict that session's oldest `queued` row
159
+ * (never `claimed`) as `dropped-overflow`, returned for loss-reporting.
160
+ *
161
+ * Storage failures (ENOSPC etc.) THROW — the caller maps a throw to
162
+ * `refused` → fall-through (the fail-safe direction, spec §1 round-5).
163
+ * Enqueue must be the caller's last fallible step before returning its
164
+ * outcome (no-throw-after-commit invariant, §2.2).
165
+ */
166
+ enqueue(input, bounds) {
167
+ const payloadBytes = Buffer.byteLength(input.payload, 'utf-8');
168
+ if (payloadBytes > bounds.maxPayloadBytes) {
169
+ return { result: 'refused', reason: 'payload-oversize' };
170
+ }
171
+ const tx = this.#db.transaction(() => {
172
+ const existing = this.#db
173
+ .prepare('SELECT state FROM entries WHERE session_key = ? AND message_id = ?')
174
+ .get(input.sessionKey, input.messageId);
175
+ if (existing) {
176
+ if (existing.state === 'expired' || existing.state === 'dropped-overflow') {
177
+ return { result: 'refused', reason: `prior-instance-terminal:${existing.state}` };
178
+ }
179
+ return { result: 'already-queued', existingState: existing.state };
180
+ }
181
+ const totalRow = this.#db
182
+ .prepare(`SELECT COUNT(*) AS n FROM entries WHERE state IN ('queued','claimed')`)
183
+ .get();
184
+ const sessRow = this.#db
185
+ .prepare(`SELECT COUNT(*) AS n FROM entries WHERE session_key = ? AND state IN ('queued','claimed')`)
186
+ .get(input.sessionKey);
187
+ if (totalRow.n >= bounds.hardMaxTotal) {
188
+ return { result: 'refused', reason: 'hard-max-total' };
189
+ }
190
+ if (totalRow.n >= bounds.maxTotal && sessRow.n === 0) {
191
+ return { result: 'refused', reason: 'max-total-first-entry' };
192
+ }
193
+ let evicted = null;
194
+ if (sessRow.n >= bounds.maxPerSession) {
195
+ // Evict this session's oldest QUEUED row (never claimed).
196
+ const victim = this.#db
197
+ .prepare(`SELECT enqueue_seq, session_key, message_id, enqueued_at, payload_bytes, sender_display
198
+ FROM entries WHERE session_key = ? AND state = 'queued'
199
+ ORDER BY enqueue_seq ASC LIMIT 1`)
200
+ .get(input.sessionKey);
201
+ if (!victim) {
202
+ // Every row claimed (all in flight) — nothing evictable.
203
+ return { result: 'refused', reason: 'max-per-session-all-claimed' };
204
+ }
205
+ this.#terminalizeInTx(victim.enqueue_seq, 'queued', 'dropped-overflow', 'overflow-evicted', input.nowIso);
206
+ evicted = {
207
+ sessionKey: victim.session_key,
208
+ messageId: victim.message_id,
209
+ enqueuedAt: victim.enqueued_at,
210
+ payloadBytes: victim.payload_bytes,
211
+ senderDisplay: victim.sender_display,
212
+ reason: 'overflow-evicted',
213
+ };
214
+ }
215
+ const senderDisplay = input.senderEnvelope
216
+ ? (input.senderEnvelope.firstName || input.senderEnvelope.username || (input.senderEnvelope.userId != null ? String(input.senderEnvelope.userId) : null))
217
+ : null;
218
+ const initialHistory = JSON.stringify([
219
+ { state: 'queued', reason: input.reason, at: input.nowIso },
220
+ ]);
221
+ const res = this.#db
222
+ .prepare(`INSERT INTO entries (
223
+ session_key, message_id, payload, payload_bytes, sender_envelope,
224
+ sender_display, topic_metadata, reason, state, enqueued_at,
225
+ enqueued_mono, boot_session_id, lease_epoch, status_history,
226
+ first_frozen_at, frozen_since
227
+ ) VALUES (
228
+ @session_key, @message_id, @payload, @payload_bytes, @sender_envelope,
229
+ @sender_display, @topic_metadata, @reason, 'queued', @enqueued_at,
230
+ @enqueued_mono, @boot_session_id, @lease_epoch, @status_history,
231
+ @first_frozen_at, @frozen_since
232
+ )`)
233
+ .run({
234
+ session_key: input.sessionKey,
235
+ message_id: input.messageId,
236
+ payload: input.payload,
237
+ payload_bytes: payloadBytes,
238
+ sender_envelope: input.senderEnvelope ? JSON.stringify(input.senderEnvelope) : null,
239
+ sender_display: senderDisplay,
240
+ topic_metadata: input.topicMetadata != null ? JSON.stringify(input.topicMetadata) : null,
241
+ reason: input.reason,
242
+ enqueued_at: input.nowIso,
243
+ enqueued_mono: input.monoMs,
244
+ boot_session_id: input.bootSessionId,
245
+ lease_epoch: input.tenure,
246
+ status_history: initialHistory,
247
+ // Rows enqueued while a pause is durably in effect are frozen at
248
+ // enqueue — same rule as freeze-at-release (§3.6 round-10).
249
+ first_frozen_at: input.frozenAtEnqueue ? input.nowIso : null,
250
+ frozen_since: input.frozenAtEnqueue ? input.nowIso : null,
251
+ });
252
+ return { result: 'queued', seq: Number(res.lastInsertRowid), evicted };
253
+ });
254
+ return tx();
255
+ }
256
+ // ── Reads ────────────────────────────────────────────────────────────
257
+ /** Point read for the custody-aware route-throw catch (§2.2): does a
258
+ * committed NON-TERMINAL row exist for THIS message? Indexed PK lookup.
259
+ * Throws propagate — the caller fails OPEN to fall-through on a read
260
+ * error (a bounded duplicate window, §5-enumerated). */
261
+ hasNonTerminalRow(sessionKey, messageId) {
262
+ const row = this.#db
263
+ .prepare(`SELECT state FROM entries WHERE session_key = ? AND message_id = ? AND state IN ('queued','claimed')`)
264
+ .get(sessionKey, messageId);
265
+ return row !== undefined;
266
+ }
267
+ getRow(seq) {
268
+ const row = this.#db.prepare('SELECT * FROM entries WHERE enqueue_seq = ?').get(seq);
269
+ return row ?? null;
270
+ }
271
+ getRowByCanonicalId(sessionKey, messageId) {
272
+ const row = this.#db
273
+ .prepare('SELECT * FROM entries WHERE session_key = ? AND message_id = ?')
274
+ .get(sessionKey, messageId);
275
+ return row ?? null;
276
+ }
277
+ /**
278
+ * Head-only per-session selection (§3.2): for each session whose LOWEST
279
+ * non-terminal seq is `queued`, due (`next_attempt_at` null or <= now), and
280
+ * not frozen, return that head row. Successors inherit the head's schedule
281
+ * by construction (they are simply never selected while the head exists).
282
+ * A session whose lowest non-terminal row is `claimed` is in flight → skip.
283
+ */
284
+ selectEligibleHeads(nowIso, limit) {
285
+ return this.#db
286
+ .prepare(`SELECT e.* FROM entries e
287
+ JOIN (
288
+ SELECT session_key, MIN(enqueue_seq) AS head_seq
289
+ FROM entries WHERE state IN ('queued','claimed')
290
+ GROUP BY session_key
291
+ ) h ON e.session_key = h.session_key AND e.enqueue_seq = h.head_seq
292
+ WHERE e.state = 'queued'
293
+ AND e.frozen_since IS NULL
294
+ AND (e.next_attempt_at IS NULL OR e.next_attempt_at <= @now)
295
+ ORDER BY e.enqueue_seq ASC
296
+ LIMIT @limit`)
297
+ .all({ now: nowIso, limit });
298
+ }
299
+ /** Queued rows with a hold clock running — the boot-rebuild source for the
300
+ * in-memory held-set cache (§3.2: the first_held_at column is AUTHORITATIVE). */
301
+ listHeldRows() {
302
+ return this.#db
303
+ .prepare(`SELECT * FROM entries WHERE state = 'queued' AND first_held_at IS NOT NULL ORDER BY enqueue_seq ASC`)
304
+ .all();
305
+ }
306
+ /** Targeted deadline write for the §6 sleep-shift (queued rows only). */
307
+ setNextAttempt(seq, nextAttemptAtIso) {
308
+ this.#db
309
+ .prepare(`UPDATE entries SET next_attempt_at = ? WHERE enqueue_seq = ? AND state = 'queued'`)
310
+ .run(nextAttemptAtIso, seq);
311
+ }
312
+ /** Scoped event-trigger reset (§3.2): make a session's queued rows due now. */
313
+ resetNextAttempt(sessionKey) {
314
+ const res = this.#db
315
+ .prepare(`UPDATE entries SET next_attempt_at = NULL WHERE session_key = ? AND state = 'queued'`)
316
+ .run(sessionKey);
317
+ return res.changes ?? 0;
318
+ }
319
+ /** All non-terminal rows (boot sweep, §5.3). */
320
+ listNonTerminal() {
321
+ return this.#db
322
+ .prepare(`SELECT * FROM entries WHERE state IN ('queued','claimed') ORDER BY enqueue_seq ASC`)
323
+ .all();
324
+ }
325
+ /** Sessions with operator-stop terminal rows still in retention — the boot
326
+ * sweep's PIS-veto scan (§3.4, STOP-scoped only). */
327
+ listOperatorStopSessions() {
328
+ return this.#db
329
+ .prepare(`SELECT DISTINCT session_key AS sk FROM entries WHERE terminal_reason = 'operator-stop'`)
330
+ .all().map((r) => r.sk);
331
+ }
332
+ /** Per-session non-terminal counts + min seq — the mirror rebuild/reconcile
333
+ * source (§2.3). GROUP BY over ≤hardMaxTotal rows. */
334
+ sessionCounts() {
335
+ return this.#db
336
+ .prepare(`SELECT session_key, COUNT(*) AS count, MIN(enqueue_seq) AS min_seq
337
+ FROM entries WHERE state IN ('queued','claimed') GROUP BY session_key`)
338
+ .all();
339
+ }
340
+ counts() {
341
+ const one = (sql, params = {}) => this.#db.prepare(sql).get(params).n;
342
+ const dayAgo = new Date(Date.now() - 24 * 3600_000).toISOString();
343
+ const oldest = this.#db
344
+ .prepare(`SELECT MIN(enqueued_at) AS m FROM entries WHERE state IN ('queued','claimed')`)
345
+ .get();
346
+ return {
347
+ queued: one(`SELECT COUNT(*) AS n FROM entries WHERE state='queued'`),
348
+ claimed: one(`SELECT COUNT(*) AS n FROM entries WHERE state='claimed'`),
349
+ held: one(`SELECT COUNT(*) AS n FROM entries WHERE state='queued' AND first_held_at IS NOT NULL`),
350
+ frozen: one(`SELECT COUNT(*) AS n FROM entries WHERE frozen_since IS NOT NULL AND state IN ('queued','claimed')`),
351
+ // delivered24h EXCLUDES possibly-not-injected rows (spec round-8/9:
352
+ // success totals never overstate; the two are summed separately).
353
+ delivered24h: one(`SELECT COUNT(*) AS n FROM entries WHERE state='delivered' AND delivered_unconfirmed=0 AND enqueued_at >= @c`, { c: dayAgo }),
354
+ deliveredUnconfirmed24h: one(`SELECT COUNT(*) AS n FROM entries WHERE state='delivered' AND delivered_unconfirmed=1 AND enqueued_at >= @c`, { c: dayAgo }),
355
+ expired24h: one(`SELECT COUNT(*) AS n FROM entries WHERE state='expired' AND enqueued_at >= @c`, { c: dayAgo }),
356
+ droppedOverflow24h: one(`SELECT COUNT(*) AS n FROM entries WHERE state='dropped-overflow' AND enqueued_at >= @c`, { c: dayAgo }),
357
+ oldestQueuedAt: oldest.m,
358
+ };
359
+ }
360
+ // ── Transitions (expected-prior-state asserted) ─────────────────────
361
+ /** Atomic claim: queued → claimed iff still queued. Returns the claimed row
362
+ * or null (lost race / no longer queued). */
363
+ claim(seq, nowIso) {
364
+ const tx = this.#db.transaction(() => {
365
+ const res = this.#db
366
+ .prepare(`UPDATE entries SET state='claimed' WHERE enqueue_seq = ? AND state = 'queued'`)
367
+ .run(seq);
368
+ if (res.changes !== 1)
369
+ return null;
370
+ this.#appendHistoryInTx(seq, 'claimed', null, nowIso);
371
+ return this.getRow(seq);
372
+ });
373
+ return tx();
374
+ }
375
+ /**
376
+ * Generic transition with expected-prior-state assert (spec §1): mismatch is
377
+ * a LOGGED NO-OP (returns false), never a throw. Terminal transitions null
378
+ * payload/envelope/metadata in the same transaction (payload hygiene) while
379
+ * the locator (timestamps, ids, sender_display, payload_bytes) survives
380
+ * (MUST 11).
381
+ */
382
+ transition(seq, expectedPrior, to, opts) {
383
+ const tx = this.#db.transaction(() => {
384
+ if (TERMINAL.includes(to)) {
385
+ const ok = this.#terminalizeInTx(seq, expectedPrior, to, opts.terminalReason ?? to, opts.nowIso, opts.deliveredUnconfirmed === true);
386
+ return ok;
387
+ }
388
+ const fields = ['state = @state'];
389
+ const params = { seq, state: to, expected: expectedPrior };
390
+ if (opts.lastError !== undefined) {
391
+ fields.push('last_error = @last_error');
392
+ params.last_error = sanitizeError(opts.lastError);
393
+ }
394
+ if (opts.attempts !== undefined) {
395
+ fields.push('attempts = @attempts');
396
+ params.attempts = opts.attempts;
397
+ }
398
+ if (opts.nextAttemptAt !== undefined) {
399
+ fields.push('next_attempt_at = @next_attempt_at');
400
+ params.next_attempt_at = opts.nextAttemptAt;
401
+ }
402
+ const res = this.#db
403
+ .prepare(`UPDATE entries SET ${fields.join(', ')} WHERE enqueue_seq = @seq AND state = @expected`)
404
+ .run(params);
405
+ if (res.changes !== 1)
406
+ return false;
407
+ this.#appendHistoryInTx(seq, to, opts.terminalReason ?? null, opts.nowIso);
408
+ return true;
409
+ });
410
+ return tx();
411
+ }
412
+ /** Release a claimed row back to queued with backoff (failed attempt, §3.3).
413
+ * When a pause is in effect the caller passes `freeze:true` — the row is
414
+ * frozen at release (§3.6 round-9). */
415
+ release(seq, opts) {
416
+ const tx = this.#db.transaction(() => {
417
+ const fields = ['state = \'queued\'', 'attempts = @attempts', 'next_attempt_at = @next'];
418
+ const params = { seq, attempts: opts.attempts, next: opts.nextAttemptAt };
419
+ if (opts.lastError !== undefined) {
420
+ fields.push('last_error = @err');
421
+ params.err = sanitizeError(opts.lastError);
422
+ }
423
+ if (opts.freeze) {
424
+ fields.push('frozen_since = @now', 'first_frozen_at = COALESCE(first_frozen_at, @now)');
425
+ params.now = opts.nowIso;
426
+ }
427
+ const res = this.#db
428
+ .prepare(`UPDATE entries SET ${fields.join(', ')} WHERE enqueue_seq = @seq AND state = 'claimed'`)
429
+ .run(params);
430
+ if (res.changes !== 1)
431
+ return false;
432
+ this.#appendHistoryInTx(seq, 'queued', opts.freeze ? 'released-frozen' : 'released', opts.nowIso);
433
+ return true;
434
+ });
435
+ return tx();
436
+ }
437
+ /** Mark a queued head held (first_held_at set once; §4.3 cumulative clock). */
438
+ markHeld(seq, nowIso) {
439
+ this.#db
440
+ .prepare(`UPDATE entries SET first_held_at = COALESCE(first_held_at, ?) WHERE enqueue_seq = ? AND state = 'queued'`)
441
+ .run(nowIso, seq);
442
+ }
443
+ #terminalizeInTx(seq, expectedPrior, to, terminalReason, nowIso, deliveredUnconfirmed = false) {
444
+ const res = this.#db
445
+ .prepare(`UPDATE entries SET
446
+ state = @to, terminal_reason = @reason,
447
+ payload = NULL, sender_envelope = NULL, topic_metadata = NULL,
448
+ frozen_since = NULL,
449
+ delivered_unconfirmed = @unconfirmed
450
+ WHERE enqueue_seq = @seq AND state = @expected`)
451
+ .run({ seq, to, reason: terminalReason, expected: expectedPrior, unconfirmed: deliveredUnconfirmed ? 1 : 0 });
452
+ if (res.changes !== 1)
453
+ return false;
454
+ this.#appendHistoryInTx(seq, to, terminalReason, nowIso);
455
+ return true;
456
+ }
457
+ #appendHistoryInTx(seq, state, reason, atIso) {
458
+ const cur = this.#db.prepare('SELECT status_history FROM entries WHERE enqueue_seq = ?').get(seq);
459
+ if (!cur)
460
+ return;
461
+ let history;
462
+ try {
463
+ const parsed = JSON.parse(cur.status_history);
464
+ history = Array.isArray(parsed) ? parsed : [];
465
+ }
466
+ catch {
467
+ history = [];
468
+ }
469
+ // States/reasons/timestamps only — never content (spec §1).
470
+ history.push(reason ? { state, reason, at: atIso } : { state, at: atIso });
471
+ while (history.length > STATUS_HISTORY_CAP)
472
+ history.shift();
473
+ this.#db.prepare('UPDATE entries SET status_history = ? WHERE enqueue_seq = ?').run(JSON.stringify(history), seq);
474
+ }
475
+ // ── Receipts (§3.4) ──────────────────────────────────────────────────
476
+ /**
477
+ * The CONDITIONAL receipt commit (§3.6): writes the injection-class receipt
478
+ * iff the queue row is still `claimed` — one transaction, so a stop's
479
+ * claimed→expired transition makes a late receipt write fail atomically
480
+ * (which aborts the inject). Returns true when the receipt committed.
481
+ */
482
+ writeReceiptIfClaimed(seq, sessionKey, messageId, nowIso) {
483
+ const tx = this.#db.transaction(() => {
484
+ const row = this.#db
485
+ .prepare(`SELECT state FROM entries WHERE enqueue_seq = ?`)
486
+ .get(seq);
487
+ if (!row || row.state !== 'claimed')
488
+ return false;
489
+ this.#db
490
+ .prepare(`INSERT OR IGNORE INTO receipts (session_key, message_id, class, created_at)
491
+ VALUES (?, ?, 'injection', ?)`)
492
+ .run(sessionKey, messageId, nowIso);
493
+ return true;
494
+ });
495
+ return tx();
496
+ }
497
+ hasReceipt(sessionKey, messageId, cls = 'injection') {
498
+ return (this.#db
499
+ .prepare('SELECT 1 AS x FROM receipts WHERE session_key = ? AND message_id = ? AND class = ?')
500
+ .get(sessionKey, messageId, cls) !== undefined);
501
+ }
502
+ /** Remote path (§3.4): receive-side receipt, keyed on the canonical id. */
503
+ recordRemoteReceipt(sessionKey, messageId, nowIso) {
504
+ const res = this.#db
505
+ .prepare(`INSERT OR IGNORE INTO receipts (session_key, message_id, class, created_at) VALUES (?, ?, 'remote', ?)`)
506
+ .run(sessionKey, messageId, nowIso);
507
+ return res.changes === 1;
508
+ }
509
+ /** Flip the `injected` marker after the local inject completes (§3.4 remote
510
+ * path round-8 — makes peer-crash-between-receipt-and-inject boot-detectable). */
511
+ markReceiptInjected(sessionKey, messageId, cls) {
512
+ this.#db
513
+ .prepare('UPDATE receipts SET injected = 1 WHERE session_key = ? AND message_id = ? AND class = ?')
514
+ .run(sessionKey, messageId, cls);
515
+ }
516
+ markReceiptReported(sessionKey, messageId, cls) {
517
+ this.#db
518
+ .prepare('UPDATE receipts SET reported = 1 WHERE session_key = ? AND message_id = ? AND class = ?')
519
+ .run(sessionKey, messageId, cls);
520
+ }
521
+ /** Unflipped (never-injected), unreported REMOTE receipts — the boot sweep's
522
+ * window-6 detection surface (§3.4 remote path). REMOTE-class only: local
523
+ * injection-class receipts never flip a marker — their loss variant is
524
+ * carried by the row's `delivered_unconfirmed` flag instead. */
525
+ findUnflippedUnreportedReceipts() {
526
+ return this.#db
527
+ .prepare(`SELECT * FROM receipts WHERE class = 'remote' AND injected = 0 AND reported = 0`)
528
+ .all();
529
+ }
530
+ /**
531
+ * Receipt pruning (§3.4, §3.2 backstop duty): receipts older than the cutoff
532
+ * are pruned — but an UNFLIPPED receipt is never silently pruned: the rows
533
+ * about to be dropped while injected=0 AND reported=0 are RETURNED so the
534
+ * caller reports them first (report once, then prune — retention is never
535
+ * extended; spec round-10). The prune itself runs after the caller's report
536
+ * via `confirmPruneReceipts`.
537
+ */
538
+ listPrunableReceipts(cutoffIso) {
539
+ const needsReport = this.#db
540
+ .prepare(`SELECT * FROM receipts WHERE class = 'remote' AND created_at < ? AND injected = 0 AND reported = 0`)
541
+ .all(cutoffIso);
542
+ const total = this.#db
543
+ .prepare('SELECT COUNT(*) AS n FROM receipts WHERE created_at < ?')
544
+ .get(cutoffIso).n;
545
+ return { silent: total - needsReport.length, needsReport };
546
+ }
547
+ confirmPruneReceipts(cutoffIso) {
548
+ const res = this.#db.prepare('DELETE FROM receipts WHERE created_at < ?').run(cutoffIso);
549
+ return res.changes ?? 0;
550
+ }
551
+ /** Delete a just-committed receipt (deliberate in-process un-commit — only
552
+ * legal while the caller KNOWS no inject ran; used by halt skip paths). */
553
+ deleteReceipt(sessionKey, messageId, cls) {
554
+ this.#db
555
+ .prepare('DELETE FROM receipts WHERE session_key = ? AND message_id = ? AND class = ?')
556
+ .run(sessionKey, messageId, cls);
557
+ }
558
+ // ── Tenure (§3.5 meta) ───────────────────────────────────────────────
559
+ /**
560
+ * Tenure source of truth (§3.5): the queue maintains its OWN
561
+ * acquisition-generation counter, persisted here, bumped iff the lease ref
562
+ * tip observed at this machine's claim names a holder ≠ self. Unchanged on
563
+ * renewals and same-holder re-acquire.
564
+ *
565
+ * Returns the current tenure id (`<selfMachineId>#<generation>`).
566
+ */
567
+ observeLeaseClaim(selfMachineId, tipHolderAtClaim) {
568
+ const tx = this.#db.transaction(() => {
569
+ const genRow = this.#db.prepare(`SELECT value FROM meta WHERE key = 'acquisition_generation'`).get();
570
+ let gen = genRow ? Number(genRow.value) : 0;
571
+ if (tipHolderAtClaim !== null && tipHolderAtClaim !== selfMachineId) {
572
+ gen += 1;
573
+ }
574
+ else if (!genRow) {
575
+ gen = 1; // first ever claim on this store
576
+ }
577
+ this.#db
578
+ .prepare(`INSERT INTO meta (key, value) VALUES ('acquisition_generation', @v)
579
+ ON CONFLICT(key) DO UPDATE SET value = @v`)
580
+ .run({ v: String(gen) });
581
+ return `${selfMachineId}#${gen}`;
582
+ });
583
+ return tx();
584
+ }
585
+ currentTenure(selfMachineId) {
586
+ const genRow = this.#db.prepare(`SELECT value FROM meta WHERE key = 'acquisition_generation'`).get();
587
+ return genRow ? `${selfMachineId}#${genRow.value}` : null;
588
+ }
589
+ /** The bare generation number (capacity-heartbeat field, §5.1). */
590
+ acquisitionGeneration() {
591
+ const genRow = this.#db.prepare(`SELECT value FROM meta WHERE key = 'acquisition_generation'`).get();
592
+ return genRow ? Number(genRow.value) : null;
593
+ }
594
+ // ── Pause / freeze (§3.6) ────────────────────────────────────────────
595
+ /** Durable pause flag — restart mid-pause stays paused. */
596
+ setPaused(paused, nowIso) {
597
+ this.#db
598
+ .prepare(`INSERT INTO meta (key, value) VALUES ('paused_at', @v)
599
+ ON CONFLICT(key) DO UPDATE SET value = @v`)
600
+ .run({ v: paused ? nowIso : '' });
601
+ }
602
+ isPaused() {
603
+ const row = this.#db.prepare(`SELECT value FROM meta WHERE key = 'paused_at'`).get();
604
+ return !!row && row.value !== '';
605
+ }
606
+ /** Freeze all QUEUED rows (pause scope pin, §3.6 round-9 — claimed rows
607
+ * complete normally and freeze at release if they fail). */
608
+ freezeQueuedRows(nowIso) {
609
+ const res = this.#db
610
+ .prepare(`UPDATE entries SET
611
+ frozen_since = @now,
612
+ first_frozen_at = COALESCE(first_frozen_at, @now)
613
+ WHERE state = 'queued' AND frozen_since IS NULL`)
614
+ .run({ now: nowIso });
615
+ return res.changes ?? 0;
616
+ }
617
+ /**
618
+ * Resume: fold each live frozen span into `total_frozen_ms`, clear
619
+ * `frozen_since`, shift `next_attempt_at` deadlines by the frozen span
620
+ * (the §6 sleep-shift pattern), and return rows whose CUMULATIVE frozen
621
+ * time now exceeds `pauseMaxMs` — the caller terminals those as
622
+ * `pause-expired` (the cumulative cap, §3.6 round-7/8).
623
+ */
624
+ resumeFrozenRows(nowIso, pauseMaxMs) {
625
+ const tx = this.#db.transaction(() => {
626
+ const frozen = this.#db
627
+ .prepare(`SELECT * FROM entries WHERE frozen_since IS NOT NULL AND state IN ('queued','claimed')`)
628
+ .all();
629
+ const nowMs = Date.parse(nowIso);
630
+ const overCap = [];
631
+ let resumed = 0;
632
+ for (const row of frozen) {
633
+ const sinceMs = Date.parse(row.frozen_since);
634
+ const span = Number.isFinite(sinceMs) ? Math.max(0, nowMs - sinceMs) : 0;
635
+ const total = row.total_frozen_ms + span;
636
+ let nextAttempt = row.next_attempt_at;
637
+ if (nextAttempt) {
638
+ const t = Date.parse(nextAttempt);
639
+ if (Number.isFinite(t))
640
+ nextAttempt = new Date(t + span).toISOString();
641
+ }
642
+ this.#db
643
+ .prepare(`UPDATE entries SET total_frozen_ms = @total, frozen_since = NULL, next_attempt_at = @next
644
+ WHERE enqueue_seq = @seq`)
645
+ .run({ total, next: nextAttempt, seq: row.enqueue_seq });
646
+ resumed += 1;
647
+ if (total > pauseMaxMs) {
648
+ const updated = this.getRow(row.enqueue_seq);
649
+ if (updated)
650
+ overCap.push(updated);
651
+ }
652
+ }
653
+ return { resumed, overCap };
654
+ });
655
+ return tx();
656
+ }
657
+ /** Live cumulative frozen span for the cap check while still paused
658
+ * (`total_frozen_ms + (now − frozen_since)`, §1 columns round-8). */
659
+ liveFrozenMs(row, nowIso) {
660
+ let live = row.total_frozen_ms;
661
+ if (row.frozen_since) {
662
+ const since = Date.parse(row.frozen_since);
663
+ const now = Date.parse(nowIso);
664
+ if (Number.isFinite(since) && Number.isFinite(now))
665
+ live += Math.max(0, now - since);
666
+ }
667
+ return live;
668
+ }
669
+ // ── TTL / pruning (§3.2 backstop duties) ─────────────────────────────
670
+ /** Rows whose TTL has expired (frozen rows excluded — TTL accounting pauses
671
+ * while frozen, §3.6). Caller terminals + loss-reports them. */
672
+ listTtlExpired(nowIso, entryTtlMs) {
673
+ const cutoff = new Date(Date.parse(nowIso) - entryTtlMs).toISOString();
674
+ return this.#db
675
+ .prepare(`SELECT * FROM entries
676
+ WHERE state = 'queued' AND frozen_since IS NULL AND enqueued_at < @cutoff`)
677
+ .all({ cutoff });
678
+ }
679
+ /** Prune terminal rows past retention (payload already nulled at terminal). */
680
+ pruneTerminal(cutoffIso) {
681
+ const res = this.#db
682
+ .prepare(`DELETE FROM entries WHERE state IN ('delivered','expired','dropped-overflow') AND enqueued_at < ?`)
683
+ .run(cutoffIso);
684
+ return res.changes ?? 0;
685
+ }
686
+ // ── Durable counters (§2.4 + observability) ──────────────────────────
687
+ incrementCounter(key, by = 1) {
688
+ this.#db
689
+ .prepare(`INSERT INTO meta (key, value) VALUES (@k, @by)
690
+ ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + @by AS TEXT)`)
691
+ .run({ k: `counter:${key}`, by: String(by) });
692
+ }
693
+ getCounter(key) {
694
+ const row = this.#db.prepare('SELECT value FROM meta WHERE key = ?').get(`counter:${key}`);
695
+ return row ? Number(row.value) : 0;
696
+ }
697
+ // ── Test-only diagnostics (no handle export) ─────────────────────────
698
+ /** File mode check support — path only; the test stats the file itself. */
699
+ countAll() {
700
+ return this.#db.prepare('SELECT COUNT(*) AS n FROM entries').get().n;
701
+ }
702
+ }
703
+ // ── Helpers ───────────────────────────────────────────────────────────
704
+ /** Sanitized last_error (spec §1): error class + length-capped message; the
705
+ * caller must never pass payload-bearing text — this cap is the backstop. */
706
+ export function sanitizeError(err) {
707
+ if (err == null)
708
+ return null;
709
+ const name = err instanceof Error ? err.constructor.name : typeof err;
710
+ const msg = err instanceof Error ? err.message : String(err);
711
+ return `${name}: ${msg}`.slice(0, LAST_ERROR_CAP);
712
+ }
713
+ export const PENDING_INBOUND_TERMINAL_STATES = TERMINAL;
714
+ export const PENDING_INBOUND_NON_TERMINAL_STATES = NON_TERMINAL;
715
+ //# sourceMappingURL=PendingInboundStore.js.map