instar 1.3.501 → 1.3.502
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.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +537 -9
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +41 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/BackupManager.d.ts.map +1 -1
- package/dist/core/BackupManager.js +7 -0
- package/dist/core/BackupManager.js.map +1 -1
- package/dist/core/DeliverMessageHandler.d.ts +13 -1
- package/dist/core/DeliverMessageHandler.d.ts.map +1 -1
- package/dist/core/DeliverMessageHandler.js +7 -0
- package/dist/core/DeliverMessageHandler.js.map +1 -1
- package/dist/core/MachinePoolRegistry.d.ts +3 -0
- package/dist/core/MachinePoolRegistry.d.ts.map +1 -1
- package/dist/core/MachinePoolRegistry.js +1 -0
- package/dist/core/MachinePoolRegistry.js.map +1 -1
- package/dist/core/OwnerSuspectBreaker.d.ts +37 -0
- package/dist/core/OwnerSuspectBreaker.d.ts.map +1 -1
- package/dist/core/OwnerSuspectBreaker.js +58 -0
- package/dist/core/OwnerSuspectBreaker.js.map +1 -1
- package/dist/core/PendingInboundStore.d.ts +310 -0
- package/dist/core/PendingInboundStore.d.ts.map +1 -0
- package/dist/core/PendingInboundStore.js +715 -0
- package/dist/core/PendingInboundStore.js.map +1 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +13 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/QueueDrainLoop.d.ts +247 -0
- package/dist/core/QueueDrainLoop.d.ts.map +1 -0
- package/dist/core/QueueDrainLoop.js +0 -0
- package/dist/core/QueueDrainLoop.js.map +1 -0
- package/dist/core/SessionRouter.d.ts +36 -2
- package/dist/core/SessionRouter.d.ts.map +1 -1
- package/dist/core/SessionRouter.js +34 -9
- package/dist/core/SessionRouter.js.map +1 -1
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +10 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/inboundQueueBootSweep.d.ts +61 -0
- package/dist/core/inboundQueueBootSweep.d.ts.map +1 -0
- package/dist/core/inboundQueueBootSweep.js +207 -0
- package/dist/core/inboundQueueBootSweep.js.map +1 -0
- package/dist/core/inboundQueueConfig.d.ts +78 -0
- package/dist/core/inboundQueueConfig.d.ts.map +1 -0
- package/dist/core/inboundQueueConfig.js +135 -0
- package/dist/core/inboundQueueConfig.js.map +1 -0
- package/dist/core/types.d.ts +23 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/messaging/TelegramAdapter.d.ts +4 -0
- package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
- package/dist/messaging/TelegramAdapter.js +12 -0
- package/dist/messaging/TelegramAdapter.js.map +1 -1
- package/dist/monitoring/guardManifest.d.ts.map +1 -1
- package/dist/monitoring/guardManifest.js +27 -0
- package/dist/monitoring/guardManifest.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +6 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/AgentServer.d.ts +2 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +1 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/routes.d.ts +4 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +25 -0
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/scripts/lint-guard-manifest.js +2 -0
- package/src/data/builtin-manifest.json +65 -65
- package/src/scaffold/templates.ts +6 -0
- package/upgrades/1.3.502.md +75 -0
- package/upgrades/side-effects/1.3.492.md +20 -0
- package/upgrades/side-effects/durable-inbound-message-queue.md +213 -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
|