@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
package/src/state.ts ADDED
@@ -0,0 +1,609 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ mkdirSync,
5
+ } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { DatabaseSync } from "node:sqlite";
8
+ import type {
9
+ ChannelIngressQueue,
10
+ ChannelIngressQueueClaim,
11
+ ChannelIngressQueueClaimRef,
12
+ ChannelIngressQueueRecord,
13
+ } from "openclaw/plugin-sdk/channel-outbound";
14
+ import type {
15
+ RelayIngressPayload,
16
+ RelaySnapshot,
17
+ } from "./types.js";
18
+
19
+ type QueueStatus = "pending" | "claimed" | "completed" | "failed";
20
+
21
+ type QueueRow = {
22
+ event_id: string;
23
+ status: QueueStatus;
24
+ payload_json: string;
25
+ metadata_json: string | null;
26
+ lane_key: string | null;
27
+ received_at: number;
28
+ updated_at: number;
29
+ attempts: number;
30
+ last_attempt_at: number | null;
31
+ last_error: string | null;
32
+ claim_token: string | null;
33
+ claim_owner: string | null;
34
+ claimed_at: number | null;
35
+ completed_at: number | null;
36
+ completed_metadata_json: string | null;
37
+ failed_at: number | null;
38
+ failed_reason: string | null;
39
+ };
40
+
41
+ type RelayQueue = ChannelIngressQueue<RelayIngressPayload>;
42
+
43
+ const databases = new Map<string, DatabaseSync>();
44
+
45
+ function ensurePrivateDirectory(path: string): void {
46
+ mkdirSync(path, { recursive: true, mode: 0o700 });
47
+ try {
48
+ chmodSync(path, 0o700);
49
+ } catch {
50
+ // Windows does not implement POSIX modes completely.
51
+ }
52
+ }
53
+
54
+ function openDatabase(path: string): DatabaseSync {
55
+ const existing = databases.get(path);
56
+ if (existing) return existing;
57
+
58
+ const db = new DatabaseSync(path);
59
+ db.exec("PRAGMA journal_mode = WAL");
60
+ db.exec("PRAGMA synchronous = FULL");
61
+ db.exec("PRAGMA busy_timeout = 30000");
62
+ db.exec(`
63
+ CREATE TABLE IF NOT EXISTS relay_ingress (
64
+ event_id TEXT PRIMARY KEY,
65
+ status TEXT NOT NULL CHECK (status IN ('pending','claimed','completed','failed')),
66
+ payload_json TEXT NOT NULL,
67
+ metadata_json TEXT,
68
+ lane_key TEXT,
69
+ received_at INTEGER NOT NULL,
70
+ updated_at INTEGER NOT NULL,
71
+ attempts INTEGER NOT NULL DEFAULT 0,
72
+ last_attempt_at INTEGER,
73
+ last_error TEXT,
74
+ claim_token TEXT,
75
+ claim_owner TEXT,
76
+ claimed_at INTEGER,
77
+ completed_at INTEGER,
78
+ completed_metadata_json TEXT,
79
+ failed_at INTEGER,
80
+ failed_reason TEXT
81
+ );
82
+ CREATE INDEX IF NOT EXISTS relay_ingress_pending_order
83
+ ON relay_ingress(status, received_at, event_id);
84
+ CREATE INDEX IF NOT EXISTS relay_ingress_claimed_order
85
+ ON relay_ingress(status, claimed_at, event_id);
86
+ CREATE TABLE IF NOT EXISTS relay_snapshot (
87
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
88
+ snapshot_json TEXT NOT NULL,
89
+ updated_at INTEGER NOT NULL
90
+ );
91
+ `);
92
+ try {
93
+ chmodSync(path, 0o600);
94
+ } catch {
95
+ // Windows does not implement POSIX modes completely.
96
+ }
97
+ databases.set(path, db);
98
+ return db;
99
+ }
100
+
101
+ function transaction<T>(db: DatabaseSync, operation: () => T): T {
102
+ db.exec("BEGIN IMMEDIATE");
103
+ try {
104
+ const value = operation();
105
+ db.exec("COMMIT");
106
+ return value;
107
+ } catch (error) {
108
+ try {
109
+ db.exec("ROLLBACK");
110
+ } catch {
111
+ // Preserve the operation error.
112
+ }
113
+ throw error;
114
+ }
115
+ }
116
+
117
+ function parseJson<T>(value: string | null): T | undefined {
118
+ if (value === null) return undefined;
119
+ return JSON.parse(value) as T;
120
+ }
121
+
122
+ function queueRecord(row: QueueRow, accountId: string): ChannelIngressQueueRecord<RelayIngressPayload> {
123
+ return {
124
+ id: row.event_id,
125
+ channelId: "relay",
126
+ accountId,
127
+ queueName: JSON.stringify(["relay", accountId]),
128
+ payload: parseJson<RelayIngressPayload>(row.payload_json) ?? {
129
+ version: 1,
130
+ rawEvent: "",
131
+ },
132
+ ...(row.metadata_json === null
133
+ ? {}
134
+ : { metadata: parseJson<unknown>(row.metadata_json) }),
135
+ receivedAt: row.received_at,
136
+ updatedAt: row.updated_at,
137
+ ...(row.lane_key === null ? {} : { laneKey: row.lane_key }),
138
+ attempts: row.attempts,
139
+ ...(row.last_attempt_at === null ? {} : { lastAttemptAt: row.last_attempt_at }),
140
+ ...(row.last_error === null ? {} : { lastError: row.last_error }),
141
+ };
142
+ }
143
+
144
+ function claimedRecord(
145
+ row: QueueRow,
146
+ accountId: string,
147
+ ): ChannelIngressQueueClaim<RelayIngressPayload> | null {
148
+ if (
149
+ row.status !== "claimed" ||
150
+ !row.claim_token ||
151
+ !row.claim_owner ||
152
+ row.claimed_at === null
153
+ ) {
154
+ return null;
155
+ }
156
+ return {
157
+ ...queueRecord(row, accountId),
158
+ claim: {
159
+ token: row.claim_token,
160
+ ownerId: row.claim_owner,
161
+ claimedAt: row.claimed_at,
162
+ },
163
+ };
164
+ }
165
+
166
+ function completedRecord(row: QueueRow, accountId: string) {
167
+ return {
168
+ id: row.event_id,
169
+ channelId: "relay",
170
+ accountId,
171
+ queueName: JSON.stringify(["relay", accountId]),
172
+ completedAt: row.completed_at ?? row.updated_at,
173
+ ...(row.completed_metadata_json === null
174
+ ? {}
175
+ : { metadata: parseJson<unknown>(row.completed_metadata_json) }),
176
+ };
177
+ }
178
+
179
+ function failedRecord(row: QueueRow, accountId: string) {
180
+ return {
181
+ id: row.event_id,
182
+ channelId: "relay",
183
+ accountId,
184
+ queueName: JSON.stringify(["relay", accountId]),
185
+ failedAt: row.failed_at ?? row.updated_at,
186
+ reason: row.failed_reason ?? "failed",
187
+ ...(row.last_error === null ? {} : { message: row.last_error }),
188
+ };
189
+ }
190
+
191
+ function selectRow(db: DatabaseSync, id: string): QueueRow | undefined {
192
+ return db
193
+ .prepare("SELECT * FROM relay_ingress WHERE event_id = ?")
194
+ .get(id) as QueueRow | undefined;
195
+ }
196
+
197
+ function claimToken(
198
+ value: string | ChannelIngressQueueClaimRef,
199
+ ): string | null {
200
+ return typeof value === "string" ? null : value.claim.token;
201
+ }
202
+
203
+ function entryId(value: string | { id: string }): string {
204
+ const id = (typeof value === "string" ? value : value.id).trim();
205
+ if (!id) throw new Error("relay: ingress event id cannot be empty");
206
+ return id;
207
+ }
208
+
209
+ function placeholders(values: readonly unknown[]): string {
210
+ return values.map(() => "?").join(",");
211
+ }
212
+
213
+ function createRelayIngressQueue(
214
+ db: DatabaseSync,
215
+ accountId: string,
216
+ now: () => number,
217
+ ): RelayQueue {
218
+ const queue: RelayQueue = {
219
+ enqueue: async (id, payload, options) =>
220
+ transaction(db, () => {
221
+ const eventId = entryId(id);
222
+ const receivedAt = options?.receivedAt ?? now();
223
+ const updatedAt = now();
224
+ const inserted = db.prepare(`
225
+ INSERT INTO relay_ingress (
226
+ event_id, status, payload_json, metadata_json, lane_key,
227
+ received_at, updated_at, attempts
228
+ ) VALUES (?, 'pending', ?, ?, ?, ?, ?, 0)
229
+ ON CONFLICT(event_id) DO NOTHING
230
+ `).run(
231
+ eventId,
232
+ JSON.stringify(payload),
233
+ options?.metadata === undefined ? null : JSON.stringify(options.metadata),
234
+ options?.laneKey ?? null,
235
+ receivedAt,
236
+ updatedAt,
237
+ );
238
+ const row = selectRow(db, eventId);
239
+ if (!row) throw new Error(`relay: failed to read ingress event ${eventId}`);
240
+ if (Number(inserted.changes) > 0) {
241
+ return {
242
+ kind: "accepted" as const,
243
+ duplicate: false as const,
244
+ record: queueRecord(row, accountId),
245
+ };
246
+ }
247
+ if (row.status === "claimed") {
248
+ const record = claimedRecord(row, accountId);
249
+ if (!record) throw new Error(`relay: corrupt claimed ingress event ${eventId}`);
250
+ return { kind: "claimed" as const, duplicate: true as const, record };
251
+ }
252
+ if (row.status === "completed") {
253
+ return {
254
+ kind: "completed" as const,
255
+ duplicate: true as const,
256
+ record: completedRecord(row, accountId),
257
+ };
258
+ }
259
+ if (row.status === "failed") {
260
+ return {
261
+ kind: "failed" as const,
262
+ duplicate: true as const,
263
+ record: failedRecord(row, accountId),
264
+ };
265
+ }
266
+ return {
267
+ kind: "pending" as const,
268
+ duplicate: true as const,
269
+ record: queueRecord(row, accountId),
270
+ };
271
+ }),
272
+
273
+ listPending: async (options) => {
274
+ const order = options?.orderBy === "id"
275
+ ? "event_id ASC"
276
+ : "received_at ASC, event_id ASC";
277
+ const limit = options?.limit === "all"
278
+ ? Number.MAX_SAFE_INTEGER
279
+ : Math.max(1, Math.floor(options?.limit ?? 100));
280
+ const rows = db
281
+ .prepare(`SELECT * FROM relay_ingress WHERE status = 'pending' ORDER BY ${order} LIMIT ?`)
282
+ .all(limit) as unknown as QueueRow[];
283
+ return rows.map((row) => queueRecord(row, accountId));
284
+ },
285
+
286
+ listClaims: async () => {
287
+ const rows = db
288
+ .prepare(`
289
+ SELECT * FROM relay_ingress
290
+ WHERE status = 'claimed'
291
+ ORDER BY claimed_at ASC, received_at ASC, event_id ASC
292
+ `)
293
+ .all() as unknown as QueueRow[];
294
+ return rows
295
+ .map((row) => claimedRecord(row, accountId))
296
+ .filter((row): row is ChannelIngressQueueClaim<RelayIngressPayload> => row !== null);
297
+ },
298
+
299
+ claimNext: async (options) => {
300
+ if (options?.staleMs !== undefined) {
301
+ await queue.recoverStaleClaims({ staleMs: options.staleMs });
302
+ }
303
+ const blocked = new Set(
304
+ [...(options?.blockedLaneKeys ?? [])]
305
+ .map((value) => value.trim())
306
+ .filter(Boolean),
307
+ );
308
+ const candidateIds =
309
+ options?.candidateIds === undefined
310
+ ? undefined
311
+ : new Set(
312
+ [...options.candidateIds]
313
+ .map((value) => value.trim())
314
+ .filter(Boolean),
315
+ );
316
+ if (candidateIds?.size === 0) return null;
317
+
318
+ return transaction(db, () => {
319
+ const order = options?.orderBy === "id"
320
+ ? "event_id ASC"
321
+ : "received_at ASC, event_id ASC";
322
+ const scanLimit = Math.max(1, Math.floor(options?.scanLimit ?? 100));
323
+ const rows = db
324
+ .prepare(`SELECT * FROM relay_ingress WHERE status = 'pending' ORDER BY ${order} LIMIT ?`)
325
+ .all(scanLimit) as unknown as QueueRow[];
326
+ let selected: { row: QueueRow; laneKey?: string } | undefined;
327
+ for (const row of rows) {
328
+ if (candidateIds && !candidateIds.has(row.event_id)) continue;
329
+ const record = queueRecord(row, accountId);
330
+ let laneKey = record.laneKey;
331
+ const derived = options?.deriveLaneKey?.(record);
332
+ if (!laneKey) {
333
+ laneKey = derived;
334
+ } else if (
335
+ derived &&
336
+ derived !== laneKey &&
337
+ options?.reconcileStoredLaneKey?.(record, laneKey, derived)
338
+ ) {
339
+ laneKey = derived;
340
+ }
341
+ if (laneKey && blocked.has(laneKey)) continue;
342
+ selected = { row, ...(laneKey ? { laneKey } : {}) };
343
+ break;
344
+ }
345
+ if (!selected) return null;
346
+
347
+ const claimedAt = now();
348
+ const token = randomUUID();
349
+ const ownerId = options?.ownerId?.trim() || String(process.pid);
350
+ const result = db.prepare(`
351
+ UPDATE relay_ingress
352
+ SET status = 'claimed', claim_token = ?, claim_owner = ?,
353
+ claimed_at = ?, updated_at = ?, lane_key = COALESCE(?, lane_key)
354
+ WHERE event_id = ? AND status = 'pending'
355
+ `).run(
356
+ token,
357
+ ownerId,
358
+ claimedAt,
359
+ claimedAt,
360
+ selected.laneKey ?? null,
361
+ selected.row.event_id,
362
+ );
363
+ if (Number(result.changes) === 0) return null;
364
+ const row = selectRow(db, selected.row.event_id);
365
+ return row ? claimedRecord(row, accountId) : null;
366
+ });
367
+ },
368
+
369
+ claim: async (id, options) =>
370
+ transaction(db, () => {
371
+ const eventId = entryId(id);
372
+ const claimedAt = now();
373
+ const token = randomUUID();
374
+ const ownerId = options?.ownerId?.trim() || String(process.pid);
375
+ const result = db.prepare(`
376
+ UPDATE relay_ingress
377
+ SET status = 'claimed', claim_token = ?, claim_owner = ?,
378
+ claimed_at = ?, updated_at = ?
379
+ WHERE event_id = ? AND status = 'pending'
380
+ `).run(token, ownerId, claimedAt, claimedAt, eventId);
381
+ if (Number(result.changes) === 0) return null;
382
+ const row = selectRow(db, eventId);
383
+ return row ? claimedRecord(row, accountId) : null;
384
+ }),
385
+
386
+ refreshClaim: async (claim, options) => {
387
+ const refreshedAt = options?.refreshedAt ?? now();
388
+ const result = db.prepare(`
389
+ UPDATE relay_ingress
390
+ SET claimed_at = ?, updated_at = ?
391
+ WHERE event_id = ? AND status = 'claimed' AND claim_token = ?
392
+ `).run(refreshedAt, refreshedAt, entryId(claim), claim.claim.token);
393
+ return Number(result.changes) > 0;
394
+ },
395
+
396
+ complete: async (idOrClaim, options) =>
397
+ transaction(db, () => {
398
+ const id = entryId(idOrClaim);
399
+ const token = claimToken(idOrClaim);
400
+ const completedAt = options?.completedAt ?? now();
401
+ const where = token === null
402
+ ? "event_id = ? AND status = 'pending'"
403
+ : "event_id = ? AND status = 'claimed' AND claim_token = ?";
404
+ const values = token === null ? [id] : [id, token];
405
+ const result = db.prepare(`
406
+ UPDATE relay_ingress
407
+ SET status = 'completed', payload_json = 'null', metadata_json = NULL,
408
+ claim_token = NULL, claim_owner = NULL, claimed_at = NULL,
409
+ completed_at = ?, completed_metadata_json = ?,
410
+ last_attempt_at = NULL, last_error = NULL, updated_at = ?
411
+ WHERE ${where}
412
+ `).run(
413
+ completedAt,
414
+ options?.metadata === undefined ? null : JSON.stringify(options.metadata),
415
+ completedAt,
416
+ ...values,
417
+ );
418
+ if (Number(result.changes) > 0) return true;
419
+ if (token !== null) return false;
420
+ const inserted = db.prepare(`
421
+ INSERT INTO relay_ingress (
422
+ event_id, status, payload_json, received_at, updated_at,
423
+ attempts, completed_at, completed_metadata_json
424
+ ) VALUES (?, 'completed', 'null', ?, ?, 0, ?, ?)
425
+ ON CONFLICT(event_id) DO NOTHING
426
+ `).run(
427
+ id,
428
+ completedAt,
429
+ completedAt,
430
+ completedAt,
431
+ options?.metadata === undefined ? null : JSON.stringify(options.metadata),
432
+ );
433
+ return Number(inserted.changes) > 0;
434
+ }),
435
+
436
+ release: async (idOrClaim, options) => {
437
+ const id = entryId(idOrClaim);
438
+ const token = claimToken(idOrClaim);
439
+ const releasedAt = options?.releasedAt ?? now();
440
+ const where = token === null
441
+ ? "event_id = ? AND status = 'pending'"
442
+ : "event_id = ? AND status = 'claimed' AND claim_token = ?";
443
+ const values = token === null ? [id] : [id, token];
444
+ const result = db.prepare(`
445
+ UPDATE relay_ingress
446
+ SET status = 'pending', claim_token = NULL, claim_owner = NULL,
447
+ claimed_at = NULL,
448
+ attempts = attempts + ?,
449
+ last_attempt_at = CASE WHEN ? = 1 THEN ? ELSE last_attempt_at END,
450
+ last_error = COALESCE(?, last_error), updated_at = ?
451
+ WHERE ${where}
452
+ `).run(
453
+ options?.recordAttempt === false ? 0 : 1,
454
+ options?.recordAttempt === false ? 0 : 1,
455
+ releasedAt,
456
+ options?.lastError ?? null,
457
+ releasedAt,
458
+ ...values,
459
+ );
460
+ return Number(result.changes) > 0;
461
+ },
462
+
463
+ fail: async (idOrClaim, options) => {
464
+ const id = entryId(idOrClaim);
465
+ const token = claimToken(idOrClaim);
466
+ const failedAt = options.failedAt ?? now();
467
+ const where = token === null
468
+ ? "event_id = ? AND status = 'pending'"
469
+ : "event_id = ? AND status = 'claimed' AND claim_token = ?";
470
+ const values = token === null ? [id] : [id, token];
471
+ const result = db.prepare(`
472
+ UPDATE relay_ingress
473
+ SET status = 'failed', claim_token = NULL, claim_owner = NULL,
474
+ claimed_at = NULL, failed_at = ?, failed_reason = ?,
475
+ last_error = ?, updated_at = ?
476
+ WHERE ${where}
477
+ `).run(
478
+ failedAt,
479
+ options.reason,
480
+ options.message ?? null,
481
+ failedAt,
482
+ ...values,
483
+ );
484
+ return Number(result.changes) > 0;
485
+ },
486
+
487
+ delete: async (idOrRecord) => {
488
+ const id = entryId(idOrRecord);
489
+ const token =
490
+ typeof idOrRecord === "string" || !("claim" in idOrRecord)
491
+ ? null
492
+ : idOrRecord.claim.token;
493
+ const result = token === null
494
+ ? db.prepare("DELETE FROM relay_ingress WHERE event_id = ?").run(id)
495
+ : db
496
+ .prepare(`
497
+ DELETE FROM relay_ingress
498
+ WHERE event_id = ? AND status = 'claimed' AND claim_token = ?
499
+ `)
500
+ .run(id, token);
501
+ return Number(result.changes) > 0;
502
+ },
503
+
504
+ recoverStaleClaims: async (options) => {
505
+ const current = options?.now ?? now();
506
+ const staleMs = Math.max(0, options?.staleMs ?? 5 * 60_000);
507
+ const cutoff = current - staleMs;
508
+ const claims = await queue.listClaims();
509
+ let recovered = 0;
510
+ for (const claim of claims) {
511
+ if (claim.claim.claimedAt > cutoff) continue;
512
+ if (options?.shouldRecover && !(await options.shouldRecover(claim))) continue;
513
+ const result = db.prepare(`
514
+ UPDATE relay_ingress
515
+ SET status = 'pending', claim_token = NULL, claim_owner = NULL,
516
+ claimed_at = NULL, attempts = attempts + 1,
517
+ last_attempt_at = ?, updated_at = ?
518
+ WHERE event_id = ? AND status = 'claimed' AND claim_token = ?
519
+ AND claimed_at <= ?
520
+ `).run(current, current, claim.id, claim.claim.token, cutoff);
521
+ recovered += Number(result.changes);
522
+ }
523
+ return recovered;
524
+ },
525
+
526
+ prune: async (options) =>
527
+ transaction(db, () => {
528
+ const current = options?.now ?? now();
529
+ const protectedIds = new Set(options?.protectIds ?? []);
530
+ let removed = 0;
531
+ for (const [status, ttl, maxEntries] of [
532
+ ["pending", options?.pendingTtlMs, options?.pendingMaxEntries],
533
+ ["completed", options?.completedTtlMs, options?.completedMaxEntries],
534
+ ["failed", options?.failedTtlMs, options?.failedMaxEntries],
535
+ ] as const) {
536
+ const rows = db
537
+ .prepare(`
538
+ SELECT event_id, updated_at FROM relay_ingress
539
+ WHERE status = ? ORDER BY updated_at ASC, event_id ASC
540
+ `)
541
+ .all(status) as unknown as Array<{ event_id: string; updated_at: number }>;
542
+ const expired = ttl === undefined
543
+ ? []
544
+ : rows.filter((row) => row.updated_at <= current - ttl);
545
+ const live = rows.filter((row) => !expired.includes(row));
546
+ const overflow =
547
+ maxEntries === undefined
548
+ ? []
549
+ : live.slice(0, Math.max(0, live.length - Math.max(0, maxEntries)));
550
+ const ids = [...new Set([...expired, ...overflow].map((row) => row.event_id))]
551
+ .filter((id) => !protectedIds.has(id));
552
+ if (ids.length === 0) continue;
553
+ const result = db
554
+ .prepare(`
555
+ DELETE FROM relay_ingress
556
+ WHERE status = ? AND event_id IN (${placeholders(ids)})
557
+ `)
558
+ .run(status, ...ids);
559
+ removed += Number(result.changes);
560
+ }
561
+ return removed;
562
+ }),
563
+ };
564
+ return queue;
565
+ }
566
+
567
+ export type RelayStateStore = {
568
+ readonly path: string;
569
+ readonly ingressQueue: RelayQueue;
570
+ replaceSnapshot(snapshot: RelaySnapshot): Promise<void>;
571
+ readSnapshot(): Promise<RelaySnapshot | undefined>;
572
+ };
573
+
574
+ export function openRelayStateStore(params: {
575
+ stateDir: string;
576
+ accountId: string;
577
+ now?: () => number;
578
+ }): RelayStateStore {
579
+ const root = join(params.stateDir, "relay");
580
+ ensurePrivateDirectory(root);
581
+ const accountHash = createHash("sha256")
582
+ .update(params.accountId)
583
+ .digest("hex")
584
+ .slice(0, 24);
585
+ const path = join(root, `account-${accountHash}.sqlite`);
586
+ const db = openDatabase(path);
587
+ const now = params.now ?? Date.now;
588
+ return {
589
+ path,
590
+ ingressQueue: createRelayIngressQueue(db, params.accountId, now),
591
+ replaceSnapshot: async (snapshot) => {
592
+ transaction(db, () => {
593
+ db.prepare(`
594
+ INSERT INTO relay_snapshot(singleton, snapshot_json, updated_at)
595
+ VALUES (1, ?, ?)
596
+ ON CONFLICT(singleton) DO UPDATE
597
+ SET snapshot_json = excluded.snapshot_json,
598
+ updated_at = excluded.updated_at
599
+ `).run(JSON.stringify(snapshot), now());
600
+ });
601
+ },
602
+ readSnapshot: async () => {
603
+ const row = db
604
+ .prepare("SELECT snapshot_json FROM relay_snapshot WHERE singleton = 1")
605
+ .get() as { snapshot_json: string } | undefined;
606
+ return row ? parseJson<RelaySnapshot>(row.snapshot_json) : undefined;
607
+ },
608
+ };
609
+ }