@forwardimpact/libbridge 0.1.4 → 0.1.6
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 +0 -1
- package/package.json +1 -3
- package/src/callback-handler.js +1 -1
- package/src/callback-registry.js +1 -1
- package/src/dispatcher.js +1 -7
- package/src/history.js +3 -1
- package/src/index.js +13 -2
- package/src/link-resume.js +92 -0
- package/src/resume-scheduler.js +7 -17
- package/src/server.js +13 -0
- package/src/discussion-context.js +0 -126
- package/src/origin-index.js +0 -49
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libbridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Threaded-channel bridge primitives — relay messages between human channels (GitHub Discussions, Microsoft Teams) and the Kata agent team.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bridge",
|
|
@@ -40,8 +40,6 @@
|
|
|
40
40
|
"test": "bun test test/*.test.js"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@forwardimpact/libindex": "^0.1.38",
|
|
44
|
-
"@forwardimpact/libstorage": "^0.1.78",
|
|
45
43
|
"@forwardimpact/libtype": "^0.1.0",
|
|
46
44
|
"@hono/node-server": "^2.0.4",
|
|
47
45
|
"hono": "^4.12.23"
|
package/src/callback-handler.js
CHANGED
|
@@ -37,7 +37,7 @@ export class CallbackHandlerError extends Error {
|
|
|
37
37
|
* @param {string} options.channel
|
|
38
38
|
* @param {import("./callback-registry.js").CallbackRegistry} options.callbacks
|
|
39
39
|
* @param {import("./acknowledgement.js").Acknowledgement} options.ack
|
|
40
|
-
* @param {import("./
|
|
40
|
+
* @param {import("./index.js").DiscussionAdapter} options.store
|
|
41
41
|
* @param {{debug?: Function, error?: Function}} options.logger
|
|
42
42
|
* @param {{startSpan: Function}} options.tracer
|
|
43
43
|
* @param {string} options.spanName
|
package/src/callback-registry.js
CHANGED
|
@@ -4,7 +4,7 @@ const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* In-memory registry of pending bridge → workflow callbacks. Hosts persist
|
|
7
|
-
* the (token, correlationId) pairs via
|
|
7
|
+
* the (token, correlationId) pairs via the discussion store so the registry
|
|
8
8
|
* can be rehydrated after a restart; this class only owns the live token →
|
|
9
9
|
* metadata mapping and TTL sweep.
|
|
10
10
|
*/
|
package/src/dispatcher.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
|
|
3
3
|
import { dispatchWorkflow } from "./dispatch.js";
|
|
4
|
-
import { appendHistory } from "./history.js";
|
|
5
4
|
|
|
6
5
|
/** Dispatch dance: resolve per-user token, register callback, ack, fire workflow, flush. */
|
|
7
6
|
export class Dispatcher {
|
|
@@ -17,7 +16,7 @@ export class Dispatcher {
|
|
|
17
16
|
* @param {object} options
|
|
18
17
|
* @param {import("./callback-registry.js").CallbackRegistry} options.callbacks
|
|
19
18
|
* @param {import("./acknowledgement.js").Acknowledgement} options.ack
|
|
20
|
-
* @param {import("./
|
|
19
|
+
* @param {import("./index.js").DiscussionAdapter} options.store
|
|
21
20
|
* @param {string} options.callbackBaseUrl - Already normalised
|
|
22
21
|
* @param {string} options.workflowFile
|
|
23
22
|
* @param {string} options.githubRepo
|
|
@@ -57,7 +56,6 @@ export class Dispatcher {
|
|
|
57
56
|
* @param {string} args.requester - Surface user id of the triggering human
|
|
58
57
|
* @param {object} args.callbackMeta - Stored on the callback token
|
|
59
58
|
* @param {unknown} [args.ackTarget] - If omitted, no acknowledgement is started
|
|
60
|
-
* @param {string} [args.historyText] - Appended to ctx.history as the user turn on success
|
|
61
59
|
* @param {object} [args.workflowInputs] - Extra fields for `dispatchWorkflow`
|
|
62
60
|
* @returns {Promise<{kind: "dispatched", token: string, correlationId: string} | {kind: "link_required", authorizeUrl: string} | {kind: "reauth_required"} | {kind: "transient", error: Error}>}
|
|
63
61
|
*/
|
|
@@ -67,7 +65,6 @@ export class Dispatcher {
|
|
|
67
65
|
requester,
|
|
68
66
|
callbackMeta,
|
|
69
67
|
ackTarget,
|
|
70
|
-
historyText,
|
|
71
68
|
workflowInputs,
|
|
72
69
|
}) {
|
|
73
70
|
if (!ctx) throw new Error("ctx is required");
|
|
@@ -94,9 +91,6 @@ export class Dispatcher {
|
|
|
94
91
|
correlationId,
|
|
95
92
|
...(workflowInputs ?? {}),
|
|
96
93
|
});
|
|
97
|
-
if (historyText !== undefined) {
|
|
98
|
-
appendHistory(ctx.history, { role: "user", text: historyText });
|
|
99
|
-
}
|
|
100
94
|
ctx.dispatches.push(Date.now());
|
|
101
95
|
ctx.last_active_at = Date.now();
|
|
102
96
|
await this.#store.add(ctx);
|
package/src/history.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* @param {number} [options.maxEntries] - Default 10
|
|
10
10
|
*/
|
|
11
11
|
export function appendHistory(history, entry, { maxEntries = 10 } = {}) {
|
|
12
|
-
|
|
12
|
+
const record = { role: entry.role, text: entry.text };
|
|
13
|
+
if (entry.author !== undefined) record.author = entry.author;
|
|
14
|
+
history.push(record);
|
|
13
15
|
while (history.length > maxEntries) history.shift();
|
|
14
16
|
}
|
package/src/index.js
CHANGED
|
@@ -10,14 +10,24 @@
|
|
|
10
10
|
* @property {string} github_repo - "owner/repo" hosting the kata-dispatch workflow
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {object} DiscussionAdapter
|
|
15
|
+
* @property {(channel: string, discussionId: string) => Promise<object|null>} loadByChannel
|
|
16
|
+
* @property {(correlationId: string) => Promise<object|null>} loadByCorrelation
|
|
17
|
+
* @property {() => Promise<Array<{correlationId: string, dueAt: number}>>} listOpenRecesses
|
|
18
|
+
* @property {(ctx: object) => Promise<void>} add
|
|
19
|
+
* @property {() => Promise<void>} flush
|
|
20
|
+
* @property {() => Promise<void>} shutdown
|
|
21
|
+
* @property {(target: object) => Promise<void>} [putPendingDispatch]
|
|
22
|
+
* @property {(linkToken: string) => Promise<object|null>} [resolvePendingDispatch]
|
|
23
|
+
*/
|
|
24
|
+
|
|
13
25
|
export { createBridgeServer } from "./server.js";
|
|
14
26
|
export { CallbackRegistry } from "./callback-registry.js";
|
|
15
27
|
export { buildPrompt } from "./prompt.js";
|
|
16
28
|
export { appendHistory } from "./history.js";
|
|
17
29
|
export { RateLimiter } from "./rate-limit.js";
|
|
18
30
|
export { dispatchWorkflow } from "./dispatch.js";
|
|
19
|
-
export { DiscussionContextStore } from "./discussion-context.js";
|
|
20
|
-
export { OriginIndex } from "./origin-index.js";
|
|
21
31
|
export { ProgressTicker } from "./progress-ticker.js";
|
|
22
32
|
export {
|
|
23
33
|
Acknowledgement,
|
|
@@ -39,3 +49,4 @@ export {
|
|
|
39
49
|
validateCallbackPayload,
|
|
40
50
|
} from "./callback-payload.js";
|
|
41
51
|
export { evaluateTrigger, parseIsoDuration } from "./triggers.js";
|
|
52
|
+
export { prepareLinkResume, createLinkCompleteHandler } from "./link-resume.js";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { normalizeBaseUrl } from "./callback-payload.js";
|
|
3
|
+
import { buildPrompt } from "./prompt.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} authorizeUrl
|
|
7
|
+
* @param {string} callbackBaseUrl
|
|
8
|
+
* @returns {{ linkToken: string, augmentedUrl: string }}
|
|
9
|
+
*/
|
|
10
|
+
export function prepareLinkResume(authorizeUrl, callbackBaseUrl) {
|
|
11
|
+
const linkToken = randomUUID();
|
|
12
|
+
const url = new URL(authorizeUrl);
|
|
13
|
+
url.searchParams.set(
|
|
14
|
+
"redirect_uri",
|
|
15
|
+
`${normalizeBaseUrl(callbackBaseUrl)}/api/link-complete`,
|
|
16
|
+
);
|
|
17
|
+
url.searchParams.set("client_state", linkToken);
|
|
18
|
+
return { linkToken, augmentedUrl: url.toString() };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} options
|
|
23
|
+
* @returns {(c: import("hono").Context) => Promise<Response>}
|
|
24
|
+
*/
|
|
25
|
+
export function createLinkCompleteHandler({
|
|
26
|
+
channel,
|
|
27
|
+
store,
|
|
28
|
+
dispatcher,
|
|
29
|
+
buildCallbackMeta,
|
|
30
|
+
}) {
|
|
31
|
+
return async (c) => {
|
|
32
|
+
const linkToken = c.req.query("state");
|
|
33
|
+
if (!linkToken) {
|
|
34
|
+
return c.html(
|
|
35
|
+
"<!DOCTYPE html><html><body><h1>Error</h1>" +
|
|
36
|
+
"<p>Missing state parameter.</p></body></html>",
|
|
37
|
+
400,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const target = await store.resolvePendingDispatch(linkToken);
|
|
42
|
+
if (!target) {
|
|
43
|
+
return c.html(
|
|
44
|
+
"<!DOCTYPE html><html><body><h1>Already processed</h1>" +
|
|
45
|
+
"<p>This link has already been used or has expired." +
|
|
46
|
+
"</p></body></html>",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ctx = await store.loadByChannel(channel, target.discussion_id);
|
|
51
|
+
if (!ctx) {
|
|
52
|
+
return c.html(
|
|
53
|
+
"<!DOCTYPE html><html><body><h1>Error</h1>" +
|
|
54
|
+
"<p>Discussion not found.</p></body></html>",
|
|
55
|
+
404,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const userTurn = [...ctx.history]
|
|
60
|
+
.reverse()
|
|
61
|
+
.find((e) => e.role === "user" && e.author === target.surface_user_id);
|
|
62
|
+
if (!userTurn) {
|
|
63
|
+
return c.html(
|
|
64
|
+
"<!DOCTYPE html><html><body><h1>Error</h1>" +
|
|
65
|
+
"<p>No message found to re-dispatch.</p></body></html>",
|
|
66
|
+
404,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const result = await dispatcher.dispatch({
|
|
71
|
+
ctx,
|
|
72
|
+
prompt: buildPrompt(userTurn.text, ctx.history),
|
|
73
|
+
requester: target.surface_user_id,
|
|
74
|
+
callbackMeta: buildCallbackMeta(ctx),
|
|
75
|
+
workflowInputs: { discussionId: target.discussion_id },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (result.kind === "dispatched") {
|
|
79
|
+
return c.html(
|
|
80
|
+
"<!DOCTYPE html><html><body><h1>Processing</h1>" +
|
|
81
|
+
"<p>Your message is being processed. " +
|
|
82
|
+
"You can close this window.</p></body></html>",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return c.html(
|
|
87
|
+
"<!DOCTYPE html><html><body><h1>Unable to dispatch</h1>" +
|
|
88
|
+
"<p>Your account could not be verified. Please try " +
|
|
89
|
+
"linking again from the conversation.</p></body></html>",
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
}
|
package/src/resume-scheduler.js
CHANGED
|
@@ -17,7 +17,7 @@ export class ResumeScheduler {
|
|
|
17
17
|
/**
|
|
18
18
|
* @param {object} options
|
|
19
19
|
* @param {import("./dispatcher.js").Dispatcher} options.dispatcher
|
|
20
|
-
* @param {import("./
|
|
20
|
+
* @param {import("./index.js").DiscussionAdapter} options.store
|
|
21
21
|
* @param {{error?: Function, info?: Function}} [options.logger]
|
|
22
22
|
* @param {string} [options.prompt] - Default "Resume requested."
|
|
23
23
|
* @param {(ctx: object) => object} [options.buildCallbackMeta]
|
|
@@ -137,15 +137,9 @@ export class ResumeScheduler {
|
|
|
137
137
|
* @returns {Promise<void>}
|
|
138
138
|
*/
|
|
139
139
|
async rearm() {
|
|
140
|
-
|
|
141
|
-
for (const
|
|
142
|
-
|
|
143
|
-
if (!open) continue;
|
|
144
|
-
for (const [correlationId, rfc] of Object.entries(open)) {
|
|
145
|
-
if (typeof rfc.due_at === "number") {
|
|
146
|
-
this.#elapsed.schedule(correlationId, rfc.due_at);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
140
|
+
const refs = await this.#store.listOpenRecesses();
|
|
141
|
+
for (const { correlationId, dueAt } of refs) {
|
|
142
|
+
this.#elapsed.schedule(correlationId, dueAt);
|
|
149
143
|
}
|
|
150
144
|
}
|
|
151
145
|
|
|
@@ -220,12 +214,8 @@ export class ResumeScheduler {
|
|
|
220
214
|
}
|
|
221
215
|
|
|
222
216
|
async #findContextWithRfc(correlationId) {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
return { ctx: record, rfc: record.open_rfcs[correlationId] };
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
return null;
|
|
217
|
+
const ctx = await this.#store.loadByCorrelation(correlationId);
|
|
218
|
+
if (!ctx) return null;
|
|
219
|
+
return { ctx, rfc: ctx.open_rfcs[correlationId] };
|
|
230
220
|
}
|
|
231
221
|
}
|
package/src/server.js
CHANGED
|
@@ -23,6 +23,7 @@ import { serve } from "@hono/node-server";
|
|
|
23
23
|
* @param {string} options.webhookPath - e.g. `/api/messages` or `/api/webhooks/github`
|
|
24
24
|
* @param {(c: import("hono").Context) => Promise<Response> | Response} options.onWebhook
|
|
25
25
|
* @param {(c: import("hono").Context) => Promise<Response> | Response} options.onCallback
|
|
26
|
+
* @param {((c: import("hono").Context) => Promise<Response> | Response)} [options.onLinkComplete]
|
|
26
27
|
* @returns {{ start: () => Promise<void>, stop: () => Promise<void>, app: import("hono").Hono, address: () => ({port: number} | null) }}
|
|
27
28
|
*/
|
|
28
29
|
export function createBridgeServer({
|
|
@@ -32,6 +33,7 @@ export function createBridgeServer({
|
|
|
32
33
|
webhookPath,
|
|
33
34
|
onWebhook,
|
|
34
35
|
onCallback,
|
|
36
|
+
onLinkComplete,
|
|
35
37
|
}) {
|
|
36
38
|
if (!config) throw new Error("config is required");
|
|
37
39
|
if (!logger) throw new Error("logger is required");
|
|
@@ -86,6 +88,17 @@ export function createBridgeServer({
|
|
|
86
88
|
}
|
|
87
89
|
});
|
|
88
90
|
|
|
91
|
+
if (onLinkComplete) {
|
|
92
|
+
app.get("/api/link-complete", async (c) => {
|
|
93
|
+
try {
|
|
94
|
+
return await onLinkComplete(c);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
logger.error("bridge.link-complete", err);
|
|
97
|
+
return c.json({ error: "Link completion failure" }, 500);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
89
102
|
let server = null;
|
|
90
103
|
|
|
91
104
|
return {
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import { BufferedIndex } from "@forwardimpact/libindex";
|
|
2
|
-
|
|
3
|
-
const DEFAULT_FLUSH_INTERVAL_MS = 5_000;
|
|
4
|
-
const DEFAULT_MAX_BUFFER_SIZE = 1_000;
|
|
5
|
-
const DEFAULT_CONVERSATION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
6
|
-
const DEFAULT_SWEEP_INTERVAL_MS = 60_000;
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Persisted thread state keyed by `(channel, discussion_id)`. Both
|
|
10
|
-
* `services/ghbridge` and `services/msbridge` write into the same store so
|
|
11
|
-
* the channel-agnostic `kata-dispatch.yml` workflow can resume conversations
|
|
12
|
-
* from either side.
|
|
13
|
-
*
|
|
14
|
-
* Record shape:
|
|
15
|
-
* {
|
|
16
|
-
* id: "<channel>:<discussion_id>",
|
|
17
|
-
* channel: "github-discussions" | "msteams",
|
|
18
|
-
* discussion_id: string,
|
|
19
|
-
* history: Array<{role: "user"|"assistant", text: string}>,
|
|
20
|
-
* participants: Array<{name, kind: "agent"|"human", external_id?, metadata?}>,
|
|
21
|
-
* open_rfcs: Record<correlationId, {trigger, opened_at, history_index_at_open}>,
|
|
22
|
-
* lead: string,
|
|
23
|
-
* pending_callbacks: Record<token, correlationId>,
|
|
24
|
-
* last_active_at: number,
|
|
25
|
-
* }
|
|
26
|
-
*
|
|
27
|
-
* @augments BufferedIndex
|
|
28
|
-
*/
|
|
29
|
-
export class DiscussionContextStore extends BufferedIndex {
|
|
30
|
-
#conversationTtlMs;
|
|
31
|
-
#sweepTimer;
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* @param {import("@forwardimpact/libstorage").StorageInterface} storage
|
|
35
|
-
* @param {object} [options]
|
|
36
|
-
* @param {string} [options.indexKey] - JSONL file name (default `discussions.jsonl`)
|
|
37
|
-
* @param {number} [options.flushIntervalMs]
|
|
38
|
-
* @param {number} [options.maxBufferSize]
|
|
39
|
-
* @param {number} [options.conversationTtlMs] - Eviction window (default 24h)
|
|
40
|
-
* @param {number} [options.sweepIntervalMs] - Sweep cadence (default 60s)
|
|
41
|
-
*/
|
|
42
|
-
constructor(
|
|
43
|
-
storage,
|
|
44
|
-
{
|
|
45
|
-
indexKey = "discussions.jsonl",
|
|
46
|
-
flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS,
|
|
47
|
-
maxBufferSize = DEFAULT_MAX_BUFFER_SIZE,
|
|
48
|
-
conversationTtlMs = DEFAULT_CONVERSATION_TTL_MS,
|
|
49
|
-
sweepIntervalMs = DEFAULT_SWEEP_INTERVAL_MS,
|
|
50
|
-
} = {},
|
|
51
|
-
) {
|
|
52
|
-
super(storage, indexKey, {
|
|
53
|
-
flush_interval: flushIntervalMs,
|
|
54
|
-
max_buffer_size: maxBufferSize,
|
|
55
|
-
});
|
|
56
|
-
this.#conversationTtlMs = conversationTtlMs;
|
|
57
|
-
this.#sweepTimer = setInterval(
|
|
58
|
-
() => this.#sweep(Date.now()),
|
|
59
|
-
sweepIntervalMs,
|
|
60
|
-
);
|
|
61
|
-
this.#sweepTimer.unref();
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Compose the `id` field for a `(channel, discussion_id)` pair.
|
|
66
|
-
* @param {string} channel
|
|
67
|
-
* @param {string} discussionId
|
|
68
|
-
* @returns {string}
|
|
69
|
-
*/
|
|
70
|
-
static keyOf(channel, discussionId) {
|
|
71
|
-
return `${channel}:${discussionId}`;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* @param {string} channel
|
|
76
|
-
* @param {string} discussionId
|
|
77
|
-
* @returns {Promise<object | null>}
|
|
78
|
-
*/
|
|
79
|
-
async loadByChannel(channel, discussionId) {
|
|
80
|
-
if (!this.loaded) await this.loadData();
|
|
81
|
-
const id = DiscussionContextStore.keyOf(channel, discussionId);
|
|
82
|
-
return this.index.get(id) ?? null;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Stop the periodic sweep timer. Called on host shutdown alongside
|
|
87
|
-
* `shutdown()` to release the interval.
|
|
88
|
-
*/
|
|
89
|
-
stopSweep() {
|
|
90
|
-
if (this.#sweepTimer) {
|
|
91
|
-
clearInterval(this.#sweepTimer);
|
|
92
|
-
this.#sweepTimer = null;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Flush buffered writes and stop the sweep timer.
|
|
98
|
-
* @returns {Promise<void>}
|
|
99
|
-
*/
|
|
100
|
-
async shutdown() {
|
|
101
|
-
this.stopSweep();
|
|
102
|
-
await super.shutdown();
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Evict records whose `last_active_at` is older than `conversationTtlMs`.
|
|
107
|
-
* Caller-driven `now` keeps unit tests deterministic.
|
|
108
|
-
* @param {number} now
|
|
109
|
-
* @returns {number}
|
|
110
|
-
*/
|
|
111
|
-
sweepNow(now) {
|
|
112
|
-
return this.#sweep(now);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
#sweep(now) {
|
|
116
|
-
let evicted = 0;
|
|
117
|
-
for (const [id, record] of this.index) {
|
|
118
|
-
const lastActive = record?.last_active_at ?? 0;
|
|
119
|
-
if (now - lastActive > this.#conversationTtlMs) {
|
|
120
|
-
this.index.delete(id);
|
|
121
|
-
evicted++;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
return evicted;
|
|
125
|
-
}
|
|
126
|
-
}
|
package/src/origin-index.js
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { BufferedIndex } from "@forwardimpact/libindex";
|
|
2
|
-
|
|
3
|
-
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Tracks comment IDs posted by the bridge so webhook handlers can skip
|
|
7
|
-
* dispatching for self-originated events. Wraps `BufferedIndex` with
|
|
8
|
-
* caller-injected `StorageInterface` per the libbridge invariant.
|
|
9
|
-
*
|
|
10
|
-
* Record shape: `{ id: "<comment_node_id>", discussion_id, posted_at }`
|
|
11
|
-
*
|
|
12
|
-
* @augments BufferedIndex
|
|
13
|
-
*/
|
|
14
|
-
export class OriginIndex extends BufferedIndex {
|
|
15
|
-
#ttlMs;
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* @param {import("@forwardimpact/libstorage").StorageInterface} storage
|
|
19
|
-
* @param {object} [options]
|
|
20
|
-
* @param {string} [options.indexKey]
|
|
21
|
-
* @param {number} [options.ttlMs] - Eviction window (default 24h)
|
|
22
|
-
*/
|
|
23
|
-
constructor(
|
|
24
|
-
storage,
|
|
25
|
-
{ indexKey = "origins.jsonl", ttlMs = DEFAULT_TTL_MS } = {},
|
|
26
|
-
) {
|
|
27
|
-
super(storage, indexKey, {
|
|
28
|
-
flush_interval: 1_000,
|
|
29
|
-
max_buffer_size: 100,
|
|
30
|
-
});
|
|
31
|
-
this.#ttlMs = ttlMs;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Evict records older than `ttlMs`.
|
|
36
|
-
* @param {number} now
|
|
37
|
-
* @returns {number} count evicted
|
|
38
|
-
*/
|
|
39
|
-
sweep(now) {
|
|
40
|
-
let evicted = 0;
|
|
41
|
-
for (const [id, record] of this.index) {
|
|
42
|
-
if (now - (record?.posted_at ?? 0) > this.#ttlMs) {
|
|
43
|
-
this.index.delete(id);
|
|
44
|
-
evicted++;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return evicted;
|
|
48
|
-
}
|
|
49
|
-
}
|