openclaw-brokerkit 0.1.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.
- package/README.md +197 -0
- package/dist/index.js +113 -0
- package/dist/src/client.js +152 -0
- package/dist/src/commands.js +101 -0
- package/dist/src/config.js +108 -0
- package/dist/src/errors.js +15 -0
- package/dist/src/generated/operator-v1.js +57 -0
- package/dist/src/http.js +279 -0
- package/dist/src/operator-v1.js +92 -0
- package/dist/src/runtime.js +312 -0
- package/dist/src/store.js +235 -0
- package/dist/src/types.js +1 -0
- package/dist/ui/assets/index-8w0_bcoC.js +143 -0
- package/dist/ui/assets/index-D8Ypbs3j.css +1 -0
- package/dist/ui/index.html +13 -0
- package/openclaw.plugin.json +98 -0
- package/package.json +77 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { BrokerClient, BrokerError } from "./client.js";
|
|
3
|
+
import { StateStore } from "./store.js";
|
|
4
|
+
export class BrokerRuntime {
|
|
5
|
+
config;
|
|
6
|
+
hooks;
|
|
7
|
+
sources = new Map();
|
|
8
|
+
requests = new Map();
|
|
9
|
+
health = new Map();
|
|
10
|
+
store;
|
|
11
|
+
timer;
|
|
12
|
+
delivering = false;
|
|
13
|
+
constructor(config, hooks) {
|
|
14
|
+
this.config = config;
|
|
15
|
+
this.hooks = hooks;
|
|
16
|
+
for (const source of config.brokers)
|
|
17
|
+
this.sources.set(source.id, {
|
|
18
|
+
config: source,
|
|
19
|
+
client: new BrokerClient(source.endpoint, () => hooks.resolveCredential(source), source.requestTimeoutMs),
|
|
20
|
+
discovered: false,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
async start(stateDir) {
|
|
24
|
+
this.store = new StateStore(stateDir);
|
|
25
|
+
this.store.retainSources([...this.sources.keys()]);
|
|
26
|
+
this.store.pruneExpired();
|
|
27
|
+
await Promise.all([...this.sources.values()].map((source) => this.startSource(source)));
|
|
28
|
+
this.timer = setInterval(() => {
|
|
29
|
+
void this.reconcileAll();
|
|
30
|
+
void this.deliverPending();
|
|
31
|
+
}, this.config.pollIntervalMs);
|
|
32
|
+
this.timer.unref();
|
|
33
|
+
}
|
|
34
|
+
async stop() {
|
|
35
|
+
if (this.timer)
|
|
36
|
+
clearInterval(this.timer);
|
|
37
|
+
for (const source of this.sources.values())
|
|
38
|
+
source.abort?.abort();
|
|
39
|
+
this.store?.close();
|
|
40
|
+
this.store = undefined;
|
|
41
|
+
}
|
|
42
|
+
snapshot() {
|
|
43
|
+
return {
|
|
44
|
+
sources: [...this.health.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
|
45
|
+
requests: [...this.requests.values()].sort((a, b) => b.requested_at.localeCompare(a.requested_at)),
|
|
46
|
+
synchronizedAt: new Date().toISOString(),
|
|
47
|
+
deliveryFailures: this.store?.failedDeliveryCount() ?? 0,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async decide(handle, action, expectedRevision, actor, options = {}) {
|
|
51
|
+
const resolved = this.requireStore().resolve(handle);
|
|
52
|
+
if (!resolved)
|
|
53
|
+
throw new Error("request_not_found");
|
|
54
|
+
const source = this.sources.get(resolved.sourceId);
|
|
55
|
+
if (!source)
|
|
56
|
+
throw new Error("source_unavailable");
|
|
57
|
+
const current = await source.client.get(resolved.requestId);
|
|
58
|
+
if (current.revision !== resolved.revision ||
|
|
59
|
+
current.revision !== expectedRevision)
|
|
60
|
+
throw new Error("revision_stale");
|
|
61
|
+
if (!current.allowed_actions.includes(action))
|
|
62
|
+
throw new Error("action_not_allowed");
|
|
63
|
+
if (options.constraints) {
|
|
64
|
+
const bounds = current.approval_bounds;
|
|
65
|
+
if (action !== "approve" ||
|
|
66
|
+
!bounds ||
|
|
67
|
+
(options.constraints.duration_seconds !== undefined &&
|
|
68
|
+
options.constraints.duration_seconds > bounds.max_duration_seconds) ||
|
|
69
|
+
(options.constraints.max_uses !== undefined &&
|
|
70
|
+
options.constraints.max_uses > bounds.max_uses))
|
|
71
|
+
throw new Error("action_not_allowed");
|
|
72
|
+
}
|
|
73
|
+
const decision = {
|
|
74
|
+
expected_revision: expectedRevision,
|
|
75
|
+
idempotency_key: deterministicDecisionKey(resolved.sourceId, resolved.requestId, expectedRevision, action, actor),
|
|
76
|
+
on_behalf_of: actor,
|
|
77
|
+
...(options.reason ? { decision_reason: options.reason } : {}),
|
|
78
|
+
...(options.constraints ? { constraints: options.constraints } : {}),
|
|
79
|
+
};
|
|
80
|
+
const updated = await this.decideWithRecovery(source, resolved.requestId, action, decision);
|
|
81
|
+
this.accept(source, updated);
|
|
82
|
+
return this.requests.get(handle) ?? this.project(source, updated, handle);
|
|
83
|
+
}
|
|
84
|
+
subscribe(value) {
|
|
85
|
+
const subscription = this.requireStore().subscribe(value);
|
|
86
|
+
for (const handle of this.requests.keys())
|
|
87
|
+
this.requireStore().enqueue(handle);
|
|
88
|
+
void this.deliverPending();
|
|
89
|
+
return subscription;
|
|
90
|
+
}
|
|
91
|
+
unsubscribe(value) {
|
|
92
|
+
return this.requireStore().unsubscribe(value);
|
|
93
|
+
}
|
|
94
|
+
subscriptions() {
|
|
95
|
+
return this.requireStore().subscriptions();
|
|
96
|
+
}
|
|
97
|
+
async startSource(source) {
|
|
98
|
+
try {
|
|
99
|
+
await this.ensureDiscovered(source);
|
|
100
|
+
await this.reconcile(source);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
this.markUnhealthy(source, error);
|
|
104
|
+
}
|
|
105
|
+
this.watch(source);
|
|
106
|
+
}
|
|
107
|
+
async decideWithRecovery(source, requestId, action, decision) {
|
|
108
|
+
try {
|
|
109
|
+
return await source.client.decide(requestId, action, decision);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (error instanceof BrokerError)
|
|
113
|
+
throw error;
|
|
114
|
+
try {
|
|
115
|
+
const observed = await source.client.get(requestId);
|
|
116
|
+
this.accept(source, observed);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
throw new Error("source_unavailable");
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
return await source.client.decide(requestId, action, decision);
|
|
123
|
+
}
|
|
124
|
+
catch (retryError) {
|
|
125
|
+
if (retryError instanceof BrokerError)
|
|
126
|
+
throw retryError;
|
|
127
|
+
throw new Error("source_unavailable");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async reconcileAll() {
|
|
132
|
+
await Promise.all([...this.sources.values()].map((source) => this.ensureDiscovered(source)
|
|
133
|
+
.then(() => this.reconcile(source))
|
|
134
|
+
.catch((error) => this.markUnhealthy(source, error))));
|
|
135
|
+
}
|
|
136
|
+
async reconcile(source) {
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
let eventCursor;
|
|
139
|
+
for (const status of ["pending", "active"]) {
|
|
140
|
+
let cursor;
|
|
141
|
+
do {
|
|
142
|
+
const page = await source.client.list(status, cursor);
|
|
143
|
+
eventCursor ??= page.event_cursor;
|
|
144
|
+
for (const request of page.requests) {
|
|
145
|
+
seen.add(request.id);
|
|
146
|
+
this.accept(source, request);
|
|
147
|
+
}
|
|
148
|
+
cursor = page.next_cursor;
|
|
149
|
+
} while (cursor);
|
|
150
|
+
}
|
|
151
|
+
for (const request of [...this.requests.values()])
|
|
152
|
+
if (request.sourceId === source.config.id && !seen.has(request.id)) {
|
|
153
|
+
this.requests.delete(request.handle);
|
|
154
|
+
this.requireStore().remove(source.config.id, request.id);
|
|
155
|
+
}
|
|
156
|
+
this.requireStore().retainRequests(source.config.id, seen);
|
|
157
|
+
this.requireStore().pruneExpired();
|
|
158
|
+
if (eventCursor)
|
|
159
|
+
this.requireStore().setCursor(source.config.id, eventCursor);
|
|
160
|
+
this.markHealthy(source);
|
|
161
|
+
}
|
|
162
|
+
watch(source) {
|
|
163
|
+
source.abort?.abort();
|
|
164
|
+
const abort = new AbortController();
|
|
165
|
+
source.abort = abort;
|
|
166
|
+
void (async () => {
|
|
167
|
+
let delay = 250;
|
|
168
|
+
while (!abort.signal.aborted) {
|
|
169
|
+
try {
|
|
170
|
+
await this.ensureDiscovered(source);
|
|
171
|
+
for await (const event of source.client.events(this.requireStore().cursor(source.config.id), abort.signal)) {
|
|
172
|
+
const current = await source.client.get(event.request_id);
|
|
173
|
+
this.accept(source, current);
|
|
174
|
+
this.requireStore().setCursor(source.config.id, event.cursor);
|
|
175
|
+
this.markHealthy(source);
|
|
176
|
+
delay = 250;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (abort.signal.aborted)
|
|
181
|
+
return;
|
|
182
|
+
let recovered = false;
|
|
183
|
+
if (error instanceof Error &&
|
|
184
|
+
"code" in error &&
|
|
185
|
+
error.code === "cursor_expired") {
|
|
186
|
+
try {
|
|
187
|
+
await this.reconcile(source);
|
|
188
|
+
recovered = true;
|
|
189
|
+
}
|
|
190
|
+
catch (reconcileError) {
|
|
191
|
+
this.markUnhealthy(source, reconcileError);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!recovered)
|
|
195
|
+
this.markUnhealthy(source, error);
|
|
196
|
+
await sleep(delay);
|
|
197
|
+
delay = Math.min(delay * 2, 30_000);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
})();
|
|
201
|
+
}
|
|
202
|
+
async ensureDiscovered(source) {
|
|
203
|
+
if (source.discovered)
|
|
204
|
+
return;
|
|
205
|
+
await source.client.discover();
|
|
206
|
+
source.discovered = true;
|
|
207
|
+
}
|
|
208
|
+
markHealthy(source) {
|
|
209
|
+
this.health.set(source.config.id, {
|
|
210
|
+
id: source.config.id,
|
|
211
|
+
label: source.config.label,
|
|
212
|
+
healthy: true,
|
|
213
|
+
lastSyncAt: new Date().toISOString(),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
accept(source, request) {
|
|
217
|
+
if (request.status !== "pending" && request.status !== "active") {
|
|
218
|
+
this.requireStore().remove(source.config.id, request.id);
|
|
219
|
+
for (const [handle, current] of this.requests)
|
|
220
|
+
if (current.sourceId === source.config.id && current.id === request.id)
|
|
221
|
+
this.requests.delete(handle);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const expires = Date.parse(request.pending_expires_at ??
|
|
225
|
+
request.active_expires_at ??
|
|
226
|
+
new Date(Date.now() + 86_400_000).toISOString());
|
|
227
|
+
const handle = this.requireStore().handle(source.config.id, request.id, request.revision, expires);
|
|
228
|
+
for (const [old, current] of this.requests)
|
|
229
|
+
if (current.sourceId === source.config.id &&
|
|
230
|
+
current.id === request.id &&
|
|
231
|
+
old !== handle)
|
|
232
|
+
this.requests.delete(old);
|
|
233
|
+
this.requests.set(handle, this.project(source, request, handle));
|
|
234
|
+
this.requireStore().enqueue(handle);
|
|
235
|
+
void this.deliverPending();
|
|
236
|
+
}
|
|
237
|
+
project(source, request, handle) {
|
|
238
|
+
return {
|
|
239
|
+
...request,
|
|
240
|
+
sourceId: source.config.id,
|
|
241
|
+
sourceLabel: source.config.label,
|
|
242
|
+
handle,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
markUnhealthy(source, error) {
|
|
246
|
+
const message = error instanceof Error ? error.message : "source unavailable";
|
|
247
|
+
this.health.set(source.config.id, {
|
|
248
|
+
id: source.config.id,
|
|
249
|
+
label: source.config.label,
|
|
250
|
+
healthy: false,
|
|
251
|
+
error: safeError(message),
|
|
252
|
+
});
|
|
253
|
+
this.hooks.log("warn", `BrokerKit source ${source.config.id} unavailable: ${safeError(message)}`);
|
|
254
|
+
}
|
|
255
|
+
requireStore() {
|
|
256
|
+
if (!this.store)
|
|
257
|
+
throw new Error("BrokerKit runtime is not started");
|
|
258
|
+
return this.store;
|
|
259
|
+
}
|
|
260
|
+
async deliverPending() {
|
|
261
|
+
if (this.delivering || !this.store)
|
|
262
|
+
return;
|
|
263
|
+
this.delivering = true;
|
|
264
|
+
try {
|
|
265
|
+
await Promise.all(this.store
|
|
266
|
+
.due(this.config.notificationConcurrency)
|
|
267
|
+
.map(async (delivery) => {
|
|
268
|
+
const request = this.requests.get(delivery.handle);
|
|
269
|
+
if (!request)
|
|
270
|
+
return;
|
|
271
|
+
try {
|
|
272
|
+
await this.hooks.deliver(delivery, notificationText(request));
|
|
273
|
+
this.store?.markDelivered(delivery.id, delivery.handle);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
this.store?.markDeliveryError(delivery.id, delivery.handle, delivery.attempts);
|
|
277
|
+
}
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
this.delivering = false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function deterministicDecisionKey(source, request, revision, action, actor) {
|
|
286
|
+
return createHash("sha256")
|
|
287
|
+
.update([
|
|
288
|
+
"brokerkit-decision-v1",
|
|
289
|
+
source,
|
|
290
|
+
request,
|
|
291
|
+
String(revision),
|
|
292
|
+
action,
|
|
293
|
+
actor,
|
|
294
|
+
].join("\0"))
|
|
295
|
+
.digest("base64url");
|
|
296
|
+
}
|
|
297
|
+
function safeError(value) {
|
|
298
|
+
return value.replace(/[\r\n]/g, " ").slice(0, 200);
|
|
299
|
+
}
|
|
300
|
+
function sleep(ms) {
|
|
301
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
302
|
+
}
|
|
303
|
+
function notificationText(request) {
|
|
304
|
+
return [
|
|
305
|
+
`${request.sourceLabel}: ${request.presentation.title}`,
|
|
306
|
+
request.presentation.summary ?? "",
|
|
307
|
+
`Handle: ${request.handle}`,
|
|
308
|
+
...request.allowed_actions.map((action) => `/brokerkit ${action} ${request.handle}`),
|
|
309
|
+
]
|
|
310
|
+
.filter(Boolean)
|
|
311
|
+
.join("\n");
|
|
312
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, openSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
const SCHEMA_VERSION = 2;
|
|
6
|
+
export class StateStore {
|
|
7
|
+
db;
|
|
8
|
+
constructor(stateDir) {
|
|
9
|
+
const directory = path.join(stateDir, "plugins", "brokerkit");
|
|
10
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
11
|
+
assertPrivateDirectory(directory);
|
|
12
|
+
const databasePath = path.join(directory, "state.sqlite");
|
|
13
|
+
if (existsSync(databasePath))
|
|
14
|
+
assertPrivateDatabase(databasePath);
|
|
15
|
+
else
|
|
16
|
+
closeSync(openSync(databasePath, "wx", 0o600));
|
|
17
|
+
this.db = new DatabaseSync(databasePath);
|
|
18
|
+
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000");
|
|
19
|
+
const version = this.db.prepare("PRAGMA user_version").get();
|
|
20
|
+
if (version.user_version !== 0 && version.user_version !== SCHEMA_VERSION)
|
|
21
|
+
throw new Error(`unsupported BrokerKit state version ${version.user_version}`);
|
|
22
|
+
if (version.user_version === 0)
|
|
23
|
+
this.createSchema();
|
|
24
|
+
}
|
|
25
|
+
close() {
|
|
26
|
+
this.db.close();
|
|
27
|
+
}
|
|
28
|
+
cursor(sourceId) {
|
|
29
|
+
return this.db
|
|
30
|
+
.prepare("SELECT cursor FROM source_cursor WHERE source_id=?")
|
|
31
|
+
.get(sourceId)?.cursor;
|
|
32
|
+
}
|
|
33
|
+
setCursor(sourceId, cursor) {
|
|
34
|
+
this.db
|
|
35
|
+
.prepare("INSERT INTO source_cursor VALUES(?,?) ON CONFLICT(source_id) DO UPDATE SET cursor=excluded.cursor")
|
|
36
|
+
.run(sourceId, cursor);
|
|
37
|
+
}
|
|
38
|
+
retainSources(sourceIds) {
|
|
39
|
+
const keep = new Set(sourceIds);
|
|
40
|
+
const rows = this.db
|
|
41
|
+
.prepare("SELECT source_id FROM source_cursor")
|
|
42
|
+
.all();
|
|
43
|
+
const remove = this.db.prepare("DELETE FROM source_cursor WHERE source_id=?");
|
|
44
|
+
this.transaction(() => {
|
|
45
|
+
for (const row of rows)
|
|
46
|
+
if (!keep.has(row.source_id))
|
|
47
|
+
remove.run(row.source_id);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
handle(sourceId, requestId, revision, expiresAtMs) {
|
|
51
|
+
const existing = this.db
|
|
52
|
+
.prepare("SELECT handle FROM request_handle WHERE source_id=? AND request_id=? AND revision=?")
|
|
53
|
+
.get(sourceId, requestId, revision);
|
|
54
|
+
if (existing)
|
|
55
|
+
return existing.handle;
|
|
56
|
+
const handle = randomBytes(16).toString("base64url");
|
|
57
|
+
this.transaction(() => {
|
|
58
|
+
this.db
|
|
59
|
+
.prepare("DELETE FROM request_handle WHERE source_id=? AND request_id=? AND revision<>?")
|
|
60
|
+
.run(sourceId, requestId, revision);
|
|
61
|
+
this.db
|
|
62
|
+
.prepare("INSERT INTO request_handle VALUES(?,?,?,?,?)")
|
|
63
|
+
.run(handle, sourceId, requestId, revision, expiresAtMs);
|
|
64
|
+
});
|
|
65
|
+
return handle;
|
|
66
|
+
}
|
|
67
|
+
resolve(handle) {
|
|
68
|
+
const value = this.db
|
|
69
|
+
.prepare("SELECT source_id,request_id,revision FROM request_handle WHERE handle=? AND expires_at_ms>?")
|
|
70
|
+
.get(handle, Date.now());
|
|
71
|
+
return (value && {
|
|
72
|
+
sourceId: value.source_id,
|
|
73
|
+
requestId: value.request_id,
|
|
74
|
+
revision: value.revision,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
remove(sourceId, requestId) {
|
|
78
|
+
this.db
|
|
79
|
+
.prepare("DELETE FROM request_handle WHERE source_id=? AND request_id=?")
|
|
80
|
+
.run(sourceId, requestId);
|
|
81
|
+
}
|
|
82
|
+
retainRequests(sourceId, requestIds) {
|
|
83
|
+
const rows = this.db
|
|
84
|
+
.prepare("SELECT DISTINCT request_id FROM request_handle WHERE source_id=?")
|
|
85
|
+
.all(sourceId);
|
|
86
|
+
const remove = this.db.prepare("DELETE FROM request_handle WHERE source_id=? AND request_id=?");
|
|
87
|
+
this.transaction(() => {
|
|
88
|
+
for (const row of rows)
|
|
89
|
+
if (!requestIds.has(row.request_id))
|
|
90
|
+
remove.run(sourceId, row.request_id);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
pruneExpired(now = Date.now()) {
|
|
94
|
+
this.db
|
|
95
|
+
.prepare("DELETE FROM request_handle WHERE expires_at_ms<=?")
|
|
96
|
+
.run(now);
|
|
97
|
+
}
|
|
98
|
+
subscribe(value) {
|
|
99
|
+
const normalized = [
|
|
100
|
+
value.channel,
|
|
101
|
+
value.target,
|
|
102
|
+
value.accountId ?? "",
|
|
103
|
+
value.threadId ?? "",
|
|
104
|
+
].join("\0");
|
|
105
|
+
const id = createHash("sha256")
|
|
106
|
+
.update("brokerkit-subscription\0")
|
|
107
|
+
.update(normalized)
|
|
108
|
+
.digest("hex");
|
|
109
|
+
this.db
|
|
110
|
+
.prepare("INSERT OR IGNORE INTO channel_subscription VALUES(?,?,?,?,?)")
|
|
111
|
+
.run(id, value.channel, value.target, value.accountId ?? null, value.threadId ?? null);
|
|
112
|
+
return { id, ...value };
|
|
113
|
+
}
|
|
114
|
+
unsubscribe(value) {
|
|
115
|
+
const result = this.db
|
|
116
|
+
.prepare("DELETE FROM channel_subscription WHERE channel=? AND target=? AND account_id IS ? AND thread_id IS ?")
|
|
117
|
+
.run(value.channel, value.target, value.accountId ?? null, value.threadId ?? null);
|
|
118
|
+
return result.changes > 0;
|
|
119
|
+
}
|
|
120
|
+
subscriptions() {
|
|
121
|
+
return this.db
|
|
122
|
+
.prepare("SELECT * FROM channel_subscription ORDER BY id")
|
|
123
|
+
.all().map((row) => ({
|
|
124
|
+
id: row.id,
|
|
125
|
+
channel: row.channel,
|
|
126
|
+
target: row.target,
|
|
127
|
+
...(row.account_id ? { accountId: row.account_id } : {}),
|
|
128
|
+
...(row.thread_id ? { threadId: row.thread_id } : {}),
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
enqueue(handle) {
|
|
132
|
+
this.db
|
|
133
|
+
.prepare("INSERT OR IGNORE INTO notification_delivery SELECT id,?,'pending',0,? FROM channel_subscription")
|
|
134
|
+
.run(handle, Date.now());
|
|
135
|
+
}
|
|
136
|
+
due(limit) {
|
|
137
|
+
return this.db
|
|
138
|
+
.prepare("SELECT s.*,d.handle,d.attempts FROM notification_delivery d JOIN channel_subscription s ON s.id=d.subscription_id WHERE d.state IN ('pending','error') AND d.attempts<8 AND d.next_attempt_at_ms<=? ORDER BY d.next_attempt_at_ms LIMIT ?")
|
|
139
|
+
.all(Date.now(), limit).map((row) => ({
|
|
140
|
+
id: row.id,
|
|
141
|
+
channel: row.channel,
|
|
142
|
+
target: row.target,
|
|
143
|
+
handle: row.handle,
|
|
144
|
+
attempts: row.attempts,
|
|
145
|
+
...(row.account_id ? { accountId: row.account_id } : {}),
|
|
146
|
+
...(row.thread_id ? { threadId: row.thread_id } : {}),
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
markDelivered(subscriptionId, handle) {
|
|
150
|
+
this.db
|
|
151
|
+
.prepare("UPDATE notification_delivery SET state='sent',attempts=attempts+1 WHERE subscription_id=? AND handle=?")
|
|
152
|
+
.run(subscriptionId, handle);
|
|
153
|
+
}
|
|
154
|
+
markDeliveryError(subscriptionId, handle, attempts) {
|
|
155
|
+
const boundedAttempts = Math.min(attempts + 1, 8);
|
|
156
|
+
const delay = Math.min(300_000, 1000 * 2 ** boundedAttempts);
|
|
157
|
+
this.db
|
|
158
|
+
.prepare("UPDATE notification_delivery SET state='error',attempts=?,next_attempt_at_ms=? WHERE subscription_id=? AND handle=?")
|
|
159
|
+
.run(boundedAttempts, Date.now() + delay, subscriptionId, handle);
|
|
160
|
+
}
|
|
161
|
+
failedDeliveryCount() {
|
|
162
|
+
const row = this.db
|
|
163
|
+
.prepare("SELECT count(*) AS count FROM notification_delivery WHERE state='error' AND attempts>=8")
|
|
164
|
+
.get();
|
|
165
|
+
return row.count;
|
|
166
|
+
}
|
|
167
|
+
transaction(operation) {
|
|
168
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
169
|
+
try {
|
|
170
|
+
const result = operation();
|
|
171
|
+
this.db.exec("COMMIT");
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
this.db.exec("ROLLBACK");
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
createSchema() {
|
|
180
|
+
this.transaction(() => {
|
|
181
|
+
this.db.exec(`
|
|
182
|
+
CREATE TABLE source_cursor(
|
|
183
|
+
source_id TEXT PRIMARY KEY,
|
|
184
|
+
cursor TEXT NOT NULL CHECK(length(cursor) BETWEEN 1 AND 4096)
|
|
185
|
+
);
|
|
186
|
+
CREATE TABLE request_handle(
|
|
187
|
+
handle TEXT PRIMARY KEY CHECK(length(handle) >= 22),
|
|
188
|
+
source_id TEXT NOT NULL,
|
|
189
|
+
request_id TEXT NOT NULL,
|
|
190
|
+
revision INTEGER NOT NULL CHECK(revision > 0),
|
|
191
|
+
expires_at_ms INTEGER NOT NULL CHECK(expires_at_ms > 0),
|
|
192
|
+
UNIQUE(source_id, request_id, revision)
|
|
193
|
+
);
|
|
194
|
+
CREATE TABLE channel_subscription(
|
|
195
|
+
id TEXT PRIMARY KEY CHECK(length(id) = 64),
|
|
196
|
+
channel TEXT NOT NULL CHECK(length(channel) > 0),
|
|
197
|
+
target TEXT NOT NULL CHECK(length(target) > 0),
|
|
198
|
+
account_id TEXT,
|
|
199
|
+
thread_id TEXT,
|
|
200
|
+
UNIQUE(channel, target, account_id, thread_id)
|
|
201
|
+
);
|
|
202
|
+
CREATE TABLE notification_delivery(
|
|
203
|
+
subscription_id TEXT NOT NULL REFERENCES channel_subscription(id)
|
|
204
|
+
ON DELETE CASCADE,
|
|
205
|
+
handle TEXT NOT NULL REFERENCES request_handle(handle) ON DELETE CASCADE,
|
|
206
|
+
state TEXT NOT NULL CHECK(state IN ('pending', 'sent', 'error')),
|
|
207
|
+
attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts BETWEEN 0 AND 8),
|
|
208
|
+
next_attempt_at_ms INTEGER NOT NULL CHECK(next_attempt_at_ms >= 0),
|
|
209
|
+
PRIMARY KEY(subscription_id, handle)
|
|
210
|
+
);
|
|
211
|
+
PRAGMA user_version=${SCHEMA_VERSION};
|
|
212
|
+
`);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function assertPrivateDirectory(directory) {
|
|
217
|
+
const stat = lstatSync(directory);
|
|
218
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
219
|
+
throw new Error("BrokerKit state directory must be a real directory");
|
|
220
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid())
|
|
221
|
+
throw new Error("BrokerKit state directory has an unsafe owner");
|
|
222
|
+
if ((stat.mode & 0o077) !== 0)
|
|
223
|
+
throw new Error("BrokerKit state directory permissions must be 0700");
|
|
224
|
+
}
|
|
225
|
+
function assertPrivateDatabase(databasePath) {
|
|
226
|
+
const stat = lstatSync(databasePath);
|
|
227
|
+
if (stat.isSymbolicLink())
|
|
228
|
+
throw new Error("BrokerKit state database must not be a symlink");
|
|
229
|
+
if (!stat.isFile())
|
|
230
|
+
throw new Error("BrokerKit state database must be a regular file");
|
|
231
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid())
|
|
232
|
+
throw new Error("BrokerKit state database has an unsafe owner");
|
|
233
|
+
if ((stat.mode & 0o177) !== 0)
|
|
234
|
+
throw new Error("BrokerKit state database permissions must be 0600");
|
|
235
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|