@relaymessenger/openclaw-plugin 0.3.4 → 0.4.0-staging.1
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/LICENSE +1 -1
- package/README.md +162 -123
- package/contracts/relay-sdk-0.3.0-staging.5.registry.json +69 -0
- package/contracts/relay-v1.lock.json +77 -0
- package/dist/index.js +2 -2
- package/dist/setup-entry.js +1 -2
- package/dist/src/accounts.js +63 -34
- package/dist/src/channel.js +144 -533
- package/dist/src/dispatch.js +257 -0
- package/dist/src/full-sync.js +24 -0
- package/dist/src/gateway.js +171 -0
- package/dist/src/inbound.js +54 -85
- package/dist/src/ingress.js +64 -0
- package/dist/src/outbound.js +48 -111
- package/dist/src/runtime.js +2 -3
- package/dist/src/state.js +492 -0
- package/dist/src/types.js +1 -3
- package/index.ts +1 -2
- package/openclaw.plugin.json +15 -18
- package/package.json +114 -40
- package/setup-entry.ts +0 -2
- package/src/accounts.ts +95 -51
- package/src/channel.ts +271 -646
- package/src/dispatch.ts +324 -0
- package/src/full-sync.ts +47 -0
- package/src/gateway.ts +216 -0
- package/src/inbound.ts +71 -122
- package/src/ingress.ts +123 -0
- package/src/outbound.ts +70 -149
- package/src/runtime.ts +4 -4
- package/src/state.ts +609 -0
- package/src/types.ts +51 -162
- package/dist/src/account-lock.js +0 -91
- package/dist/src/client.js +0 -13
- package/dist/src/cursor-store.js +0 -136
- package/dist/src/inbound-dedupe.js +0 -175
- package/dist/src/invocations.js +0 -47
- package/dist/src/lifecycle.js +0 -35
- package/dist/src/poll-loop.js +0 -137
- package/dist/src/responding.js +0 -36
- package/dist/src/security.js +0 -26
- package/dist/src/state-files.js +0 -243
- package/dist/src/vendor/relay-sdk/client.js +0 -163
- package/dist/src/vendor/relay-sdk/errors.js +0 -45
- package/dist/src/vendor/relay-sdk/types.js +0 -2
- package/dist/src/vendor/relay-sdk/url.js +0 -39
- package/src/account-lock.ts +0 -108
- package/src/client.ts +0 -51
- package/src/cursor-store.ts +0 -186
- package/src/inbound-dedupe.ts +0 -241
- package/src/invocations.ts +0 -58
- package/src/lifecycle.ts +0 -42
- package/src/poll-loop.ts +0 -173
- package/src/responding.ts +0 -52
- package/src/security.ts +0 -36
- package/src/state-files.ts +0 -298
- package/src/vendor/relay-sdk/README.md +0 -28
- package/src/vendor/relay-sdk/client.ts +0 -293
- package/src/vendor/relay-sdk/errors.ts +0 -61
- package/src/vendor/relay-sdk/types.ts +0 -82
- package/src/vendor/relay-sdk/url.ts +0 -43
package/dist/src/outbound.js
CHANGED
|
@@ -1,119 +1,56 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
40
|
-
? 0
|
|
41
|
-
: `
|
|
42
|
-
|
|
43
|
-
?
|
|
44
|
-
: `relay-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
});
|
|
65
|
-
const first = result.messages[0];
|
|
66
|
-
if (!first) {
|
|
67
|
-
throw new RelayApiError("relay: 202 carried no messages", { kind: "retryable" });
|
|
68
|
-
}
|
|
69
|
-
return { messageId: first.id, messages: result.messages };
|
|
70
|
-
}
|
|
71
|
-
catch (error) {
|
|
72
|
-
lastError = error;
|
|
73
|
-
if (!(error instanceof RelayApiError) || !error.retryable || params.signal?.aborted) {
|
|
74
|
-
throw error;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
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);
|
|
79
31
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
...(params.invocationId ? { invocationId: params.invocationId } : {}),
|
|
95
|
-
idempotencyKey: params.idempotencyKey,
|
|
96
|
-
});
|
|
97
|
-
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
|
+
};
|
|
98
46
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return { status: "unresolved", error: error.message, retryable: false };
|
|
106
|
-
}
|
|
107
|
-
if (error.retryable) {
|
|
108
|
-
return { status: "unresolved", error: error.message, retryable: true };
|
|
109
|
-
}
|
|
110
|
-
if (error.kind === "auth") {
|
|
111
|
-
return { status: "unresolved", error: error.message, retryable: false };
|
|
112
|
-
}
|
|
113
|
-
// Deterministic rejection (403/404/422): the original request would have
|
|
114
|
-
// been rejected identically, so nothing reached the conversation.
|
|
115
|
-
return { status: "not_sent" };
|
|
116
|
-
}
|
|
117
|
-
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
|
+
};
|
|
118
53
|
}
|
|
54
|
+
return { status: "not_sent" };
|
|
119
55
|
}
|
|
56
|
+
//# sourceMappingURL=outbound.js.map
|
package/dist/src/runtime.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
|
|
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