@relaymessenger/openclaw-plugin 0.3.3 → 0.4.0-staging.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +159 -124
  3. package/contracts/relay-sdk-0.3.0-staging.4.registry.json +58 -0
  4. package/contracts/relay-v1.lock.json +77 -0
  5. package/dist/index.js +2 -2
  6. package/dist/setup-entry.js +1 -2
  7. package/dist/src/accounts.js +63 -34
  8. package/dist/src/channel.js +144 -498
  9. package/dist/src/dispatch.js +257 -0
  10. package/dist/src/full-sync.js +24 -0
  11. package/dist/src/gateway.js +171 -0
  12. package/dist/src/inbound.js +54 -80
  13. package/dist/src/ingress.js +64 -0
  14. package/dist/src/outbound.js +48 -109
  15. package/dist/src/runtime.js +2 -3
  16. package/dist/src/state.js +492 -0
  17. package/dist/src/types.js +1 -3
  18. package/index.ts +1 -2
  19. package/openclaw.plugin.json +15 -18
  20. package/package.json +113 -40
  21. package/setup-entry.ts +0 -2
  22. package/src/accounts.ts +95 -51
  23. package/src/channel.ts +271 -611
  24. package/src/dispatch.ts +324 -0
  25. package/src/full-sync.ts +47 -0
  26. package/src/gateway.ts +216 -0
  27. package/src/inbound.ts +71 -111
  28. package/src/ingress.ts +123 -0
  29. package/src/outbound.ts +70 -142
  30. package/src/runtime.ts +4 -4
  31. package/src/state.ts +609 -0
  32. package/src/types.ts +51 -148
  33. package/dist/src/account-lock.js +0 -91
  34. package/dist/src/client.js +0 -229
  35. package/dist/src/cursor-store.js +0 -136
  36. package/dist/src/inbound-dedupe.js +0 -175
  37. package/dist/src/lifecycle.js +0 -35
  38. package/dist/src/poll-loop.js +0 -125
  39. package/dist/src/responding.js +0 -13
  40. package/dist/src/security.js +0 -26
  41. package/dist/src/state-files.js +0 -167
  42. package/src/account-lock.ts +0 -108
  43. package/src/client.ts +0 -330
  44. package/src/cursor-store.ts +0 -186
  45. package/src/inbound-dedupe.ts +0 -241
  46. package/src/lifecycle.ts +0 -42
  47. package/src/poll-loop.ts +0 -161
  48. package/src/responding.ts +0 -21
  49. package/src/security.ts +0 -36
  50. package/src/state-files.ts +0 -212
@@ -1,117 +1,56 @@
1
- // Durable outbound sends: every logical send carries an
2
- // Idempotency-Key; internal retries and unknown-send reconciliation replay
3
- // the same key, so a retry can never duplicate a visible message
4
- // (server contract: commitMessage.ts idempotent replay).
5
- import { createHash } from "node:crypto";
6
- import { RelayApiError } from "./client.js";
7
- /**
8
- * Per-part text ceiling declared to core's renderer so long agent replies are
9
- * split into multiple messages instead of truncated. Server caps a text part at 8 KiB UTF-8
10
- * (server/src/domain/commitMessage.ts MAX_TEXT_BYTES); 2000 chars is safe for
11
- * any UTF-8 content (4 bytes/char worst case).
12
- */
13
- export const RELAY_TEXT_CHUNK_LIMIT = 2_000;
14
- const IDEMPOTENCY_KEY_MAX = 255;
15
- /**
16
- * Idempotency key for one logical send. When core supplies a durable delivery
17
- * queue id, the key is a stable function of (queueId, part) so internal
18
- * retries and reconciliation replay the exact same key. Without a queue id a
19
- * fresh key is minted: identical intentional sends must remain distinct.
20
- *
21
- * The part term names WHICH piece of one delivery this is. Core supplies
22
- * `deliveryPartIndex` from 2026.7.2-beta.5 onward and that is authoritative.
23
- * Older cores do not: they call `enqueueDelivery` once, mint ONE queue id, and
24
- * then hand the channel each chunk of a long reply under it. Defaulting the
25
- * missing index to 0 would key every chunk to `...:0`, so the server would
26
- * replay the first chunk for each of the rest and the person would receive a
27
- * long answer truncated to its opening chunk with no error anywhere.
28
- *
29
- * So when core cannot say which part this is, the part term is a digest of the
30
- * part's own text. Sibling chunks differ, so each commits; a retry reproduces
31
- * the same text, so it replays. This is not the content-in-the-key mistake
32
- * that defeats conflict detection: the digest stands in FOR the position core
33
- * did not give us, it does not replace it. Two byte-identical chunks in one
34
- * delivery do collapse to one message, which is the residual cost of an
35
- * unindexed core and is bounded to a repeat the reader would see twice.
36
- */
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { Relay, RelayAPIError, } from "@relaymessenger/sdk";
3
+ export const RELAY_TEXT_CHUNK_LIMIT = 10_000;
4
+ const IDEMPOTENCY_KEY_MAX_LENGTH = 255;
5
+ export function createRelaySdkClient(account) {
6
+ return new Relay({
7
+ apiKey: account.token,
8
+ baseURL: account.baseUrl,
9
+ });
10
+ }
37
11
  export function deriveRelayIdempotencyKey(params) {
38
12
  const queueId = params.deliveryQueueId?.trim();
39
- const part = params.deliveryPartIndex ?? (params.partText === undefined
40
- ? 0
41
- : `t${createHash("sha256").update(params.partText).digest("hex").slice(0, 16)}`);
42
- const key = queueId
43
- ? `relay-send:${queueId}:${part}`
44
- : `relay-send:${(params.random ?? (() => crypto.randomUUID()))()}`;
45
- // Server accepts 8-255 chars; the prefix guarantees the minimum.
46
- if (key.length <= IDEMPOTENCY_KEY_MAX) {
47
- return key;
48
- }
49
- // Preserve uniqueness when an opaque core queue id is unusually long; a
50
- // simple prefix slice could erase the part index and collapse two chunks.
51
- return `relay-send:h:${createHash("sha256").update(key).digest("hex")}`;
13
+ const raw = queueId
14
+ ? `relay-openclaw:${queueId}:${params.deliveryPartIndex ?? 0}`
15
+ : `relay-openclaw:${(params.random ?? randomUUID)()}`;
16
+ return raw.length <= IDEMPOTENCY_KEY_MAX_LENGTH
17
+ ? raw
18
+ : `relay-openclaw:sha256:${createHash("sha256").update(raw).digest("hex")}`;
52
19
  }
53
20
  export async function sendRelayText(params) {
54
- let lastError;
55
- for (let attempt = 0; attempt < 3; attempt += 1) {
56
- try {
57
- const result = await params.client.sendMessage({
58
- conversationId: params.conversationId,
59
- parts: [{ type: "text", text: params.text }],
60
- ...(params.replyToId ? { replyTo: { message_id: params.replyToId } } : {}),
61
- idempotencyKey: params.idempotencyKey,
62
- ...(params.signal ? { signal: params.signal } : {}),
63
- });
64
- const first = result.messages[0];
65
- if (!first) {
66
- throw new RelayApiError("relay: 202 carried no messages", { kind: "retryable" });
67
- }
68
- return { messageId: first.id, messages: result.messages };
69
- }
70
- catch (error) {
71
- lastError = error;
72
- if (!(error instanceof RelayApiError) || !error.retryable || params.signal?.aborted) {
73
- throw error;
74
- }
75
- }
76
- }
77
- throw lastError;
21
+ await params.onPlatformSendDispatch?.();
22
+ return await params.relay.chats.messages.send(params.chatId, {
23
+ message: {
24
+ parts: [{ type: "text", value: params.text }],
25
+ idempotency_key: params.idempotencyKey,
26
+ ...(params.replyToId
27
+ ? { reply_to: { message_id: params.replyToId } }
28
+ : {}),
29
+ },
30
+ }, params.signal ? { signal: params.signal } : undefined);
78
31
  }
79
- /**
80
- * Reconcile a send whose platform outcome is unknown: replay the POST with the
81
- * same idempotency key and body. By server contract the replay either performs
82
- * the send exactly once or returns the originally committed messages — either
83
- * way the visible outcome is the one set of messages the key names, never a
84
- * duplicate.
85
- */
86
- export async function reconcileRelayUnknownSend(params) {
87
- try {
88
- const result = await sendRelayText({
89
- client: params.client,
90
- conversationId: params.conversationId,
91
- text: params.text,
92
- replyToId: params.replyToId ?? null,
93
- idempotencyKey: params.idempotencyKey,
94
- });
95
- return { status: "sent", messageId: result.messageId, messages: result.messages };
32
+ export function classifyUnknownRelaySend(error) {
33
+ if (!(error instanceof RelayAPIError)) {
34
+ return {
35
+ status: "unresolved",
36
+ error: error instanceof Error ? error.message : String(error),
37
+ retryable: true,
38
+ };
39
+ }
40
+ if (error.retryable) {
41
+ return {
42
+ status: "unresolved",
43
+ error: error.message,
44
+ retryable: true,
45
+ };
96
46
  }
97
- catch (error) {
98
- if (error instanceof RelayApiError) {
99
- if (error.kind === "conflict") {
100
- // Key already used with a different request body: the original send
101
- // reached the server but we cannot recover its receipt. Do not retry —
102
- // a retry with a fresh key would duplicate the visible message.
103
- return { status: "unresolved", error: error.message, retryable: false };
104
- }
105
- if (error.retryable) {
106
- return { status: "unresolved", error: error.message, retryable: true };
107
- }
108
- if (error.kind === "auth") {
109
- return { status: "unresolved", error: error.message, retryable: false };
110
- }
111
- // Deterministic rejection (403/404/422): the original request would have
112
- // been rejected identically, so nothing reached the conversation.
113
- return { status: "not_sent" };
114
- }
115
- return { status: "unresolved", error: String(error), retryable: true };
47
+ if (error.status === 409) {
48
+ return {
49
+ status: "unresolved",
50
+ error: error.message,
51
+ retryable: false,
52
+ };
116
53
  }
54
+ return { status: "not_sent" };
117
55
  }
56
+ //# sourceMappingURL=outbound.js.map
@@ -1,8 +1,7 @@
1
- // Injected plugin runtime store (qa-channel pattern): defineChannelPluginEntry
2
- // calls setRelayRuntime, and gateway/inbound code reads it lazily.
3
- import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
1
+ import { createPluginRuntimeStore, } from "openclaw/plugin-sdk/runtime-store";
4
2
  const { setRuntime: setRelayRuntime, getRuntime: getRelayRuntime } = createPluginRuntimeStore({
5
3
  pluginId: "relay",
6
4
  errorMessage: "Relay runtime not initialized",
7
5
  });
8
6
  export { getRelayRuntime, setRelayRuntime };
7
+ //# sourceMappingURL=runtime.js.map
@@ -0,0 +1,492 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmodSync, mkdirSync, } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ const databases = new Map();
6
+ function ensurePrivateDirectory(path) {
7
+ mkdirSync(path, { recursive: true, mode: 0o700 });
8
+ try {
9
+ chmodSync(path, 0o700);
10
+ }
11
+ catch {
12
+ // Windows does not implement POSIX modes completely.
13
+ }
14
+ }
15
+ function openDatabase(path) {
16
+ const existing = databases.get(path);
17
+ if (existing)
18
+ return existing;
19
+ const db = new DatabaseSync(path);
20
+ db.exec("PRAGMA journal_mode = WAL");
21
+ db.exec("PRAGMA synchronous = FULL");
22
+ db.exec("PRAGMA busy_timeout = 30000");
23
+ db.exec(`
24
+ CREATE TABLE IF NOT EXISTS relay_ingress (
25
+ event_id TEXT PRIMARY KEY,
26
+ status TEXT NOT NULL CHECK (status IN ('pending','claimed','completed','failed')),
27
+ payload_json TEXT NOT NULL,
28
+ metadata_json TEXT,
29
+ lane_key TEXT,
30
+ received_at INTEGER NOT NULL,
31
+ updated_at INTEGER NOT NULL,
32
+ attempts INTEGER NOT NULL DEFAULT 0,
33
+ last_attempt_at INTEGER,
34
+ last_error TEXT,
35
+ claim_token TEXT,
36
+ claim_owner TEXT,
37
+ claimed_at INTEGER,
38
+ completed_at INTEGER,
39
+ completed_metadata_json TEXT,
40
+ failed_at INTEGER,
41
+ failed_reason TEXT
42
+ );
43
+ CREATE INDEX IF NOT EXISTS relay_ingress_pending_order
44
+ ON relay_ingress(status, received_at, event_id);
45
+ CREATE INDEX IF NOT EXISTS relay_ingress_claimed_order
46
+ ON relay_ingress(status, claimed_at, event_id);
47
+ CREATE TABLE IF NOT EXISTS relay_snapshot (
48
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
49
+ snapshot_json TEXT NOT NULL,
50
+ updated_at INTEGER NOT NULL
51
+ );
52
+ `);
53
+ try {
54
+ chmodSync(path, 0o600);
55
+ }
56
+ catch {
57
+ // Windows does not implement POSIX modes completely.
58
+ }
59
+ databases.set(path, db);
60
+ return db;
61
+ }
62
+ function transaction(db, operation) {
63
+ db.exec("BEGIN IMMEDIATE");
64
+ try {
65
+ const value = operation();
66
+ db.exec("COMMIT");
67
+ return value;
68
+ }
69
+ catch (error) {
70
+ try {
71
+ db.exec("ROLLBACK");
72
+ }
73
+ catch {
74
+ // Preserve the operation error.
75
+ }
76
+ throw error;
77
+ }
78
+ }
79
+ function parseJson(value) {
80
+ if (value === null)
81
+ return undefined;
82
+ return JSON.parse(value);
83
+ }
84
+ function queueRecord(row, accountId) {
85
+ return {
86
+ id: row.event_id,
87
+ channelId: "relay",
88
+ accountId,
89
+ queueName: JSON.stringify(["relay", accountId]),
90
+ payload: parseJson(row.payload_json) ?? {
91
+ version: 1,
92
+ rawEvent: "",
93
+ },
94
+ ...(row.metadata_json === null
95
+ ? {}
96
+ : { metadata: parseJson(row.metadata_json) }),
97
+ receivedAt: row.received_at,
98
+ updatedAt: row.updated_at,
99
+ ...(row.lane_key === null ? {} : { laneKey: row.lane_key }),
100
+ attempts: row.attempts,
101
+ ...(row.last_attempt_at === null ? {} : { lastAttemptAt: row.last_attempt_at }),
102
+ ...(row.last_error === null ? {} : { lastError: row.last_error }),
103
+ };
104
+ }
105
+ function claimedRecord(row, accountId) {
106
+ if (row.status !== "claimed" ||
107
+ !row.claim_token ||
108
+ !row.claim_owner ||
109
+ row.claimed_at === null) {
110
+ return null;
111
+ }
112
+ return {
113
+ ...queueRecord(row, accountId),
114
+ claim: {
115
+ token: row.claim_token,
116
+ ownerId: row.claim_owner,
117
+ claimedAt: row.claimed_at,
118
+ },
119
+ };
120
+ }
121
+ function completedRecord(row, accountId) {
122
+ return {
123
+ id: row.event_id,
124
+ channelId: "relay",
125
+ accountId,
126
+ queueName: JSON.stringify(["relay", accountId]),
127
+ completedAt: row.completed_at ?? row.updated_at,
128
+ ...(row.completed_metadata_json === null
129
+ ? {}
130
+ : { metadata: parseJson(row.completed_metadata_json) }),
131
+ };
132
+ }
133
+ function failedRecord(row, accountId) {
134
+ return {
135
+ id: row.event_id,
136
+ channelId: "relay",
137
+ accountId,
138
+ queueName: JSON.stringify(["relay", accountId]),
139
+ failedAt: row.failed_at ?? row.updated_at,
140
+ reason: row.failed_reason ?? "failed",
141
+ ...(row.last_error === null ? {} : { message: row.last_error }),
142
+ };
143
+ }
144
+ function selectRow(db, id) {
145
+ return db
146
+ .prepare("SELECT * FROM relay_ingress WHERE event_id = ?")
147
+ .get(id);
148
+ }
149
+ function claimToken(value) {
150
+ return typeof value === "string" ? null : value.claim.token;
151
+ }
152
+ function entryId(value) {
153
+ const id = (typeof value === "string" ? value : value.id).trim();
154
+ if (!id)
155
+ throw new Error("relay: ingress event id cannot be empty");
156
+ return id;
157
+ }
158
+ function placeholders(values) {
159
+ return values.map(() => "?").join(",");
160
+ }
161
+ function createRelayIngressQueue(db, accountId, now) {
162
+ const queue = {
163
+ enqueue: async (id, payload, options) => transaction(db, () => {
164
+ const eventId = entryId(id);
165
+ const receivedAt = options?.receivedAt ?? now();
166
+ const updatedAt = now();
167
+ const inserted = db.prepare(`
168
+ INSERT INTO relay_ingress (
169
+ event_id, status, payload_json, metadata_json, lane_key,
170
+ received_at, updated_at, attempts
171
+ ) VALUES (?, 'pending', ?, ?, ?, ?, ?, 0)
172
+ ON CONFLICT(event_id) DO NOTHING
173
+ `).run(eventId, JSON.stringify(payload), options?.metadata === undefined ? null : JSON.stringify(options.metadata), options?.laneKey ?? null, receivedAt, updatedAt);
174
+ const row = selectRow(db, eventId);
175
+ if (!row)
176
+ throw new Error(`relay: failed to read ingress event ${eventId}`);
177
+ if (Number(inserted.changes) > 0) {
178
+ return {
179
+ kind: "accepted",
180
+ duplicate: false,
181
+ record: queueRecord(row, accountId),
182
+ };
183
+ }
184
+ if (row.status === "claimed") {
185
+ const record = claimedRecord(row, accountId);
186
+ if (!record)
187
+ throw new Error(`relay: corrupt claimed ingress event ${eventId}`);
188
+ return { kind: "claimed", duplicate: true, record };
189
+ }
190
+ if (row.status === "completed") {
191
+ return {
192
+ kind: "completed",
193
+ duplicate: true,
194
+ record: completedRecord(row, accountId),
195
+ };
196
+ }
197
+ if (row.status === "failed") {
198
+ return {
199
+ kind: "failed",
200
+ duplicate: true,
201
+ record: failedRecord(row, accountId),
202
+ };
203
+ }
204
+ return {
205
+ kind: "pending",
206
+ duplicate: true,
207
+ record: queueRecord(row, accountId),
208
+ };
209
+ }),
210
+ listPending: async (options) => {
211
+ const order = options?.orderBy === "id"
212
+ ? "event_id ASC"
213
+ : "received_at ASC, event_id ASC";
214
+ const limit = options?.limit === "all"
215
+ ? Number.MAX_SAFE_INTEGER
216
+ : Math.max(1, Math.floor(options?.limit ?? 100));
217
+ const rows = db
218
+ .prepare(`SELECT * FROM relay_ingress WHERE status = 'pending' ORDER BY ${order} LIMIT ?`)
219
+ .all(limit);
220
+ return rows.map((row) => queueRecord(row, accountId));
221
+ },
222
+ listClaims: async () => {
223
+ const rows = db
224
+ .prepare(`
225
+ SELECT * FROM relay_ingress
226
+ WHERE status = 'claimed'
227
+ ORDER BY claimed_at ASC, received_at ASC, event_id ASC
228
+ `)
229
+ .all();
230
+ return rows
231
+ .map((row) => claimedRecord(row, accountId))
232
+ .filter((row) => row !== null);
233
+ },
234
+ claimNext: async (options) => {
235
+ if (options?.staleMs !== undefined) {
236
+ await queue.recoverStaleClaims({ staleMs: options.staleMs });
237
+ }
238
+ const blocked = new Set([...(options?.blockedLaneKeys ?? [])]
239
+ .map((value) => value.trim())
240
+ .filter(Boolean));
241
+ const candidateIds = options?.candidateIds === undefined
242
+ ? undefined
243
+ : new Set([...options.candidateIds]
244
+ .map((value) => value.trim())
245
+ .filter(Boolean));
246
+ if (candidateIds?.size === 0)
247
+ return null;
248
+ return transaction(db, () => {
249
+ const order = options?.orderBy === "id"
250
+ ? "event_id ASC"
251
+ : "received_at ASC, event_id ASC";
252
+ const scanLimit = Math.max(1, Math.floor(options?.scanLimit ?? 100));
253
+ const rows = db
254
+ .prepare(`SELECT * FROM relay_ingress WHERE status = 'pending' ORDER BY ${order} LIMIT ?`)
255
+ .all(scanLimit);
256
+ let selected;
257
+ for (const row of rows) {
258
+ if (candidateIds && !candidateIds.has(row.event_id))
259
+ continue;
260
+ const record = queueRecord(row, accountId);
261
+ let laneKey = record.laneKey;
262
+ const derived = options?.deriveLaneKey?.(record);
263
+ if (!laneKey) {
264
+ laneKey = derived;
265
+ }
266
+ else if (derived &&
267
+ derived !== laneKey &&
268
+ options?.reconcileStoredLaneKey?.(record, laneKey, derived)) {
269
+ laneKey = derived;
270
+ }
271
+ if (laneKey && blocked.has(laneKey))
272
+ continue;
273
+ selected = { row, ...(laneKey ? { laneKey } : {}) };
274
+ break;
275
+ }
276
+ if (!selected)
277
+ return null;
278
+ const claimedAt = now();
279
+ const token = randomUUID();
280
+ const ownerId = options?.ownerId?.trim() || String(process.pid);
281
+ const result = db.prepare(`
282
+ UPDATE relay_ingress
283
+ SET status = 'claimed', claim_token = ?, claim_owner = ?,
284
+ claimed_at = ?, updated_at = ?, lane_key = COALESCE(?, lane_key)
285
+ WHERE event_id = ? AND status = 'pending'
286
+ `).run(token, ownerId, claimedAt, claimedAt, selected.laneKey ?? null, selected.row.event_id);
287
+ if (Number(result.changes) === 0)
288
+ return null;
289
+ const row = selectRow(db, selected.row.event_id);
290
+ return row ? claimedRecord(row, accountId) : null;
291
+ });
292
+ },
293
+ claim: async (id, options) => transaction(db, () => {
294
+ const eventId = entryId(id);
295
+ const claimedAt = now();
296
+ const token = randomUUID();
297
+ const ownerId = options?.ownerId?.trim() || String(process.pid);
298
+ const result = db.prepare(`
299
+ UPDATE relay_ingress
300
+ SET status = 'claimed', claim_token = ?, claim_owner = ?,
301
+ claimed_at = ?, updated_at = ?
302
+ WHERE event_id = ? AND status = 'pending'
303
+ `).run(token, ownerId, claimedAt, claimedAt, eventId);
304
+ if (Number(result.changes) === 0)
305
+ return null;
306
+ const row = selectRow(db, eventId);
307
+ return row ? claimedRecord(row, accountId) : null;
308
+ }),
309
+ refreshClaim: async (claim, options) => {
310
+ const refreshedAt = options?.refreshedAt ?? now();
311
+ const result = db.prepare(`
312
+ UPDATE relay_ingress
313
+ SET claimed_at = ?, updated_at = ?
314
+ WHERE event_id = ? AND status = 'claimed' AND claim_token = ?
315
+ `).run(refreshedAt, refreshedAt, entryId(claim), claim.claim.token);
316
+ return Number(result.changes) > 0;
317
+ },
318
+ complete: async (idOrClaim, options) => transaction(db, () => {
319
+ const id = entryId(idOrClaim);
320
+ const token = claimToken(idOrClaim);
321
+ const completedAt = options?.completedAt ?? now();
322
+ const where = token === null
323
+ ? "event_id = ? AND status = 'pending'"
324
+ : "event_id = ? AND status = 'claimed' AND claim_token = ?";
325
+ const values = token === null ? [id] : [id, token];
326
+ const result = db.prepare(`
327
+ UPDATE relay_ingress
328
+ SET status = 'completed', payload_json = 'null', metadata_json = NULL,
329
+ claim_token = NULL, claim_owner = NULL, claimed_at = NULL,
330
+ completed_at = ?, completed_metadata_json = ?,
331
+ last_attempt_at = NULL, last_error = NULL, updated_at = ?
332
+ WHERE ${where}
333
+ `).run(completedAt, options?.metadata === undefined ? null : JSON.stringify(options.metadata), completedAt, ...values);
334
+ if (Number(result.changes) > 0)
335
+ return true;
336
+ if (token !== null)
337
+ return false;
338
+ const inserted = db.prepare(`
339
+ INSERT INTO relay_ingress (
340
+ event_id, status, payload_json, received_at, updated_at,
341
+ attempts, completed_at, completed_metadata_json
342
+ ) VALUES (?, 'completed', 'null', ?, ?, 0, ?, ?)
343
+ ON CONFLICT(event_id) DO NOTHING
344
+ `).run(id, completedAt, completedAt, completedAt, options?.metadata === undefined ? null : JSON.stringify(options.metadata));
345
+ return Number(inserted.changes) > 0;
346
+ }),
347
+ release: async (idOrClaim, options) => {
348
+ const id = entryId(idOrClaim);
349
+ const token = claimToken(idOrClaim);
350
+ const releasedAt = options?.releasedAt ?? now();
351
+ const where = token === null
352
+ ? "event_id = ? AND status = 'pending'"
353
+ : "event_id = ? AND status = 'claimed' AND claim_token = ?";
354
+ const values = token === null ? [id] : [id, token];
355
+ const result = db.prepare(`
356
+ UPDATE relay_ingress
357
+ SET status = 'pending', claim_token = NULL, claim_owner = NULL,
358
+ claimed_at = NULL,
359
+ attempts = attempts + ?,
360
+ last_attempt_at = CASE WHEN ? = 1 THEN ? ELSE last_attempt_at END,
361
+ last_error = COALESCE(?, last_error), updated_at = ?
362
+ WHERE ${where}
363
+ `).run(options?.recordAttempt === false ? 0 : 1, options?.recordAttempt === false ? 0 : 1, releasedAt, options?.lastError ?? null, releasedAt, ...values);
364
+ return Number(result.changes) > 0;
365
+ },
366
+ fail: async (idOrClaim, options) => {
367
+ const id = entryId(idOrClaim);
368
+ const token = claimToken(idOrClaim);
369
+ const failedAt = options.failedAt ?? now();
370
+ const where = token === null
371
+ ? "event_id = ? AND status = 'pending'"
372
+ : "event_id = ? AND status = 'claimed' AND claim_token = ?";
373
+ const values = token === null ? [id] : [id, token];
374
+ const result = db.prepare(`
375
+ UPDATE relay_ingress
376
+ SET status = 'failed', claim_token = NULL, claim_owner = NULL,
377
+ claimed_at = NULL, failed_at = ?, failed_reason = ?,
378
+ last_error = ?, updated_at = ?
379
+ WHERE ${where}
380
+ `).run(failedAt, options.reason, options.message ?? null, failedAt, ...values);
381
+ return Number(result.changes) > 0;
382
+ },
383
+ delete: async (idOrRecord) => {
384
+ const id = entryId(idOrRecord);
385
+ const token = typeof idOrRecord === "string" || !("claim" in idOrRecord)
386
+ ? null
387
+ : idOrRecord.claim.token;
388
+ const result = token === null
389
+ ? db.prepare("DELETE FROM relay_ingress WHERE event_id = ?").run(id)
390
+ : db
391
+ .prepare(`
392
+ DELETE FROM relay_ingress
393
+ WHERE event_id = ? AND status = 'claimed' AND claim_token = ?
394
+ `)
395
+ .run(id, token);
396
+ return Number(result.changes) > 0;
397
+ },
398
+ recoverStaleClaims: async (options) => {
399
+ const current = options?.now ?? now();
400
+ const staleMs = Math.max(0, options?.staleMs ?? 5 * 60_000);
401
+ const cutoff = current - staleMs;
402
+ const claims = await queue.listClaims();
403
+ let recovered = 0;
404
+ for (const claim of claims) {
405
+ if (claim.claim.claimedAt > cutoff)
406
+ continue;
407
+ if (options?.shouldRecover && !(await options.shouldRecover(claim)))
408
+ continue;
409
+ const result = db.prepare(`
410
+ UPDATE relay_ingress
411
+ SET status = 'pending', claim_token = NULL, claim_owner = NULL,
412
+ claimed_at = NULL, attempts = attempts + 1,
413
+ last_attempt_at = ?, updated_at = ?
414
+ WHERE event_id = ? AND status = 'claimed' AND claim_token = ?
415
+ AND claimed_at <= ?
416
+ `).run(current, current, claim.id, claim.claim.token, cutoff);
417
+ recovered += Number(result.changes);
418
+ }
419
+ return recovered;
420
+ },
421
+ prune: async (options) => transaction(db, () => {
422
+ const current = options?.now ?? now();
423
+ const protectedIds = new Set(options?.protectIds ?? []);
424
+ let removed = 0;
425
+ for (const [status, ttl, maxEntries] of [
426
+ ["pending", options?.pendingTtlMs, options?.pendingMaxEntries],
427
+ ["completed", options?.completedTtlMs, options?.completedMaxEntries],
428
+ ["failed", options?.failedTtlMs, options?.failedMaxEntries],
429
+ ]) {
430
+ const rows = db
431
+ .prepare(`
432
+ SELECT event_id, updated_at FROM relay_ingress
433
+ WHERE status = ? ORDER BY updated_at ASC, event_id ASC
434
+ `)
435
+ .all(status);
436
+ const expired = ttl === undefined
437
+ ? []
438
+ : rows.filter((row) => row.updated_at <= current - ttl);
439
+ const live = rows.filter((row) => !expired.includes(row));
440
+ const overflow = maxEntries === undefined
441
+ ? []
442
+ : live.slice(0, Math.max(0, live.length - Math.max(0, maxEntries)));
443
+ const ids = [...new Set([...expired, ...overflow].map((row) => row.event_id))]
444
+ .filter((id) => !protectedIds.has(id));
445
+ if (ids.length === 0)
446
+ continue;
447
+ const result = db
448
+ .prepare(`
449
+ DELETE FROM relay_ingress
450
+ WHERE status = ? AND event_id IN (${placeholders(ids)})
451
+ `)
452
+ .run(status, ...ids);
453
+ removed += Number(result.changes);
454
+ }
455
+ return removed;
456
+ }),
457
+ };
458
+ return queue;
459
+ }
460
+ export function openRelayStateStore(params) {
461
+ const root = join(params.stateDir, "relay");
462
+ ensurePrivateDirectory(root);
463
+ const accountHash = createHash("sha256")
464
+ .update(params.accountId)
465
+ .digest("hex")
466
+ .slice(0, 24);
467
+ const path = join(root, `account-${accountHash}.sqlite`);
468
+ const db = openDatabase(path);
469
+ const now = params.now ?? Date.now;
470
+ return {
471
+ path,
472
+ ingressQueue: createRelayIngressQueue(db, params.accountId, now),
473
+ replaceSnapshot: async (snapshot) => {
474
+ transaction(db, () => {
475
+ db.prepare(`
476
+ INSERT INTO relay_snapshot(singleton, snapshot_json, updated_at)
477
+ VALUES (1, ?, ?)
478
+ ON CONFLICT(singleton) DO UPDATE
479
+ SET snapshot_json = excluded.snapshot_json,
480
+ updated_at = excluded.updated_at
481
+ `).run(JSON.stringify(snapshot), now());
482
+ });
483
+ },
484
+ readSnapshot: async () => {
485
+ const row = db
486
+ .prepare("SELECT snapshot_json FROM relay_snapshot WHERE singleton = 1")
487
+ .get();
488
+ return row ? parseJson(row.snapshot_json) : undefined;
489
+ },
490
+ };
491
+ }
492
+ //# sourceMappingURL=state.js.map
package/dist/src/types.js CHANGED
@@ -1,4 +1,2 @@
1
- // Relay wire types plus the plugin's config and resolved-account shapes. The
2
- // long-poll receive contract is GET /v1/events?cursor&timeout&limit ->
3
- // { events, next_cursor }, cursor N acknowledges everything <= N.
4
1
  export {};
2
+ //# sourceMappingURL=types.js.map