@pellux/goodvibes-daemon 1.28.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/CHANGELOG.md +383 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/goodvibes-daemon +100 -0
- package/bin/launcher-support.js +226 -0
- package/package.json +96 -0
- package/scripts/check-bun.sh +20 -0
- package/scripts/postinstall.js +244 -0
- package/src/cli/command-catalog.ts +828 -0
- package/src/cli/completion.ts +299 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/parser.ts +55 -0
- package/src/cli/surface-catalog.ts +26 -0
- package/src/cli/types.ts +63 -0
- package/src/cluster/daemon-ws-call.ts +235 -0
- package/src/cluster/raw-reply-route.ts +111 -0
- package/src/config/checkpoint-settings.ts +113 -0
- package/src/config/run-daemon-config-migration.ts +47 -0
- package/src/config/secret-config.ts +175 -0
- package/src/config/secrets.ts +71 -0
- package/src/config/surface.ts +24 -0
- package/src/core/pairing-banner.ts +82 -0
- package/src/daemon/cli.ts +878 -0
- package/src/daemon/config-command.ts +281 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +43 -0
- package/src/daemon/handlers/credentials.ts +139 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/aggregator.ts +375 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
- package/src/daemon/handlers/inbox/index.ts +221 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +239 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
- package/src/daemon/handlers/inbox/providers/email.ts +176 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
- package/src/daemon/handlers/index.ts +117 -0
- package/src/daemon/handlers/register.ts +180 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +120 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +303 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +213 -0
- package/src/daemon/handlers/triage/pipeline.ts +274 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/daemon/lifecycle.ts +41 -0
- package/src/daemon/local-daemon-state.ts +233 -0
- package/src/daemon/pair-command.ts +301 -0
- package/src/daemon/provision-wake-model.ts +81 -0
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +333 -0
- package/src/daemon/send/composition.ts +100 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/daemon/service-commands.ts +530 -0
- package/src/daemon/sessions-command.ts +209 -0
- package/src/daemon/status-command.ts +481 -0
- package/src/daemon/webui-command.ts +339 -0
- package/src/runtime/boot-tasks.ts +110 -0
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/conversation-rewind-port.ts +171 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +76 -0
- package/src/runtime/device-posture-composition.ts +115 -0
- package/src/runtime/disposal-wiring.ts +101 -0
- package/src/runtime/fleet-needs-input-push.ts +61 -0
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/hosted-session-composition.ts +128 -0
- package/src/runtime/index.ts +100 -0
- package/src/runtime/knowledge-services.ts +101 -0
- package/src/runtime/legacy-daemon-migration.ts +605 -0
- package/src/runtime/legacy-daemon-reconcile.ts +448 -0
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/notification-dispatch.ts +86 -0
- package/src/runtime/plugin-composition.ts +111 -0
- package/src/runtime/runtime-services-types.ts +268 -0
- package/src/runtime/services.ts +756 -0
- package/src/runtime/trigger-services.ts +62 -0
- package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
- package/src/runtime/trust/trust-gated-approvals.ts +169 -0
- package/src/runtime/update-check.ts +61 -0
- package/src/runtime/workspace-checkpointing.ts +116 -0
- package/src/testing/daemon-fixture.ts +276 -0
- package/src/testing/hosted-session-failures.ts +92 -0
- package/src/version.ts +26 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Persistent cursor + item store for the inbound feed.
|
|
3
|
+
//
|
|
4
|
+
// Backed by HandlerSqliteStore (sql.js WASM) at
|
|
5
|
+
// {wd}/.goodvibes/tui/operator/inbox.sqlite
|
|
6
|
+
//
|
|
7
|
+
// Two tables:
|
|
8
|
+
// items(id PK, provider, kind, fromDigest, subjectPreview, bodyPreview,
|
|
9
|
+
// routeId, receivedAt INT, unread INT)
|
|
10
|
+
// cursors(provider PK, nextSince INT)
|
|
11
|
+
//
|
|
12
|
+
// Dedup is by items.id (upsert). nextSince advances monotonically per provider
|
|
13
|
+
// = max(receivedAt) ever seen. Triage metadata is NOT persisted here: it is
|
|
14
|
+
// applied downstream at the triage overlay layer (triage/integration.ts) over a
|
|
15
|
+
// separate store, so this feed store carries only the raw inbound fields.
|
|
16
|
+
//
|
|
17
|
+
// RETENTION. The items table is bounded by BOTH an age TTL and a count cap, and
|
|
18
|
+
// the sweep runs at init() — the recovery point, right after the database file
|
|
19
|
+
// is opened — and then on a timer for the life of the store, so a daemon that
|
|
20
|
+
// stays up for weeks keeps reclaiming. `pruneOlderThan` used to exist with no
|
|
21
|
+
// production caller at all, which meant the table grew without bound in
|
|
22
|
+
// practice. Reclaimed counts are handed to the `onSweep` hook (counts only —
|
|
23
|
+
// message previews and sender ids never reach a log line).
|
|
24
|
+
//
|
|
25
|
+
// Cursors are deliberately NOT reaped: they are monotonic watermarks, so
|
|
26
|
+
// dropping one would re-deliver everything a provider ever sent.
|
|
27
|
+
//
|
|
28
|
+
// Idempotence/concurrency: a sweep re-run immediately reclaims nothing (the
|
|
29
|
+
// DELETEs are set-based over the current contents). Two processes opening the
|
|
30
|
+
// same file each hold their own sql.js snapshot and `save()` writes the whole
|
|
31
|
+
// file via temp+rename, so the last writer wins — that whole-file model is
|
|
32
|
+
// HandlerSqliteStore's, and deletion converging on the same surviving set is
|
|
33
|
+
// what makes concurrent sweeps safe rather than corrupting.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
import { HandlerSqliteStore } from '../sqlite-store.ts';
|
|
37
|
+
import type { InboundChannelItem } from './provider-adapter.ts';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Age TTL for feed items: rows whose receivedAt is older than this are dropped
|
|
41
|
+
* on every sweep. Long enough that "what did that person say last month" still
|
|
42
|
+
* works, short enough that the table cannot grow indefinitely.
|
|
43
|
+
*/
|
|
44
|
+
export const INBOX_ITEM_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Count cap for feed items: the newest this many rows survive a sweep, older
|
|
48
|
+
* ones are dropped. Guards the case the TTL cannot — a very chatty month.
|
|
49
|
+
*/
|
|
50
|
+
export const INBOX_ITEM_CAP = 5_000;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Cadence of the background retention sweep. The first sweep happens at init();
|
|
54
|
+
* this timer is what keeps it from being a startup-only reap.
|
|
55
|
+
*/
|
|
56
|
+
export const INBOX_SWEEP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
|
57
|
+
|
|
58
|
+
const DEFAULT_STORE_FILE_NAME = 'inbox.sqlite';
|
|
59
|
+
|
|
60
|
+
/** Result of one retention sweep. Counts only — never item content. */
|
|
61
|
+
export interface InboxSweepSummary {
|
|
62
|
+
/** Unix ms of the sweep. */
|
|
63
|
+
readonly at: number;
|
|
64
|
+
/** Rows removed by the age TTL. */
|
|
65
|
+
readonly expired: number;
|
|
66
|
+
/** Rows removed by the count cap. */
|
|
67
|
+
readonly capped: number;
|
|
68
|
+
/** Rows left in the items table afterwards. */
|
|
69
|
+
readonly remaining: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface InboxCursorStoreOptions {
|
|
73
|
+
/** Override the age TTL (tests / embedders). Defaults to INBOX_ITEM_TTL_MS. */
|
|
74
|
+
readonly itemTtlMs?: number;
|
|
75
|
+
/** Override the count cap (tests / embedders). Defaults to INBOX_ITEM_CAP. */
|
|
76
|
+
readonly itemCap?: number;
|
|
77
|
+
/** Sweep cadence; 0 or less disables the timer (the init sweep still runs). */
|
|
78
|
+
readonly sweepIntervalMs?: number;
|
|
79
|
+
/** Called after a sweep that reclaimed at least one row. Counts only. */
|
|
80
|
+
readonly onSweep?: (summary: InboxSweepSummary) => void;
|
|
81
|
+
/** Called when a sweep failed, so retention problems are visible rather than swallowed. */
|
|
82
|
+
readonly onSweepError?: (message: string) => void;
|
|
83
|
+
/** Clock seam (tests). Defaults to Date.now. */
|
|
84
|
+
readonly now?: () => number;
|
|
85
|
+
/** Timer seams (tests). Default to the globals. */
|
|
86
|
+
readonly setIntervalImpl?: typeof setInterval;
|
|
87
|
+
readonly clearIntervalImpl?: typeof clearInterval;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const SCHEMA: string[] = [
|
|
91
|
+
`CREATE TABLE IF NOT EXISTS items (
|
|
92
|
+
id TEXT PRIMARY KEY,
|
|
93
|
+
provider TEXT NOT NULL,
|
|
94
|
+
kind TEXT NOT NULL,
|
|
95
|
+
fromDigest TEXT NOT NULL,
|
|
96
|
+
subjectPreview TEXT NOT NULL,
|
|
97
|
+
bodyPreview TEXT NOT NULL,
|
|
98
|
+
routeId TEXT,
|
|
99
|
+
receivedAt INTEGER NOT NULL,
|
|
100
|
+
unread INTEGER NOT NULL
|
|
101
|
+
)`,
|
|
102
|
+
`CREATE INDEX IF NOT EXISTS idx_items_provider_received
|
|
103
|
+
ON items(provider, receivedAt)`,
|
|
104
|
+
`CREATE INDEX IF NOT EXISTS idx_items_received
|
|
105
|
+
ON items(receivedAt)`,
|
|
106
|
+
`CREATE TABLE IF NOT EXISTS cursors (
|
|
107
|
+
provider TEXT PRIMARY KEY,
|
|
108
|
+
nextSince INTEGER NOT NULL
|
|
109
|
+
)`,
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
interface ItemRow {
|
|
113
|
+
id: string;
|
|
114
|
+
provider: string;
|
|
115
|
+
kind: string;
|
|
116
|
+
fromDigest: string;
|
|
117
|
+
subjectPreview: string;
|
|
118
|
+
bodyPreview: string;
|
|
119
|
+
routeId: string | null;
|
|
120
|
+
receivedAt: number;
|
|
121
|
+
unread: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A position in the feed's total order (receivedAt DESC, id ASC). Paging is
|
|
126
|
+
* keyset rather than OFFSET because the feed is written to while it is being
|
|
127
|
+
* read: an OFFSET page re-anchors on every insert, so a caller walking pages
|
|
128
|
+
* while a poll lands would see items twice and skip others. A key resumes from
|
|
129
|
+
* a row, not from a count, so an insert above the cursor cannot shift it.
|
|
130
|
+
*/
|
|
131
|
+
export interface InboxPosition {
|
|
132
|
+
readonly receivedAt: number;
|
|
133
|
+
readonly id: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface InboxQuery {
|
|
137
|
+
providers?: readonly string[];
|
|
138
|
+
since?: number;
|
|
139
|
+
/** Resume strictly AFTER this position in the feed order. */
|
|
140
|
+
after?: InboxPosition;
|
|
141
|
+
/** Max items returned across the whole query. */
|
|
142
|
+
limit: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class InboxCursorStore {
|
|
146
|
+
private readonly store: HandlerSqliteStore;
|
|
147
|
+
private dirty = false;
|
|
148
|
+
private readonly itemTtlMs: number;
|
|
149
|
+
private readonly itemCap: number;
|
|
150
|
+
private readonly sweepIntervalMs: number;
|
|
151
|
+
private readonly onSweep: ((summary: InboxSweepSummary) => void) | undefined;
|
|
152
|
+
private readonly onSweepError: ((message: string) => void) | undefined;
|
|
153
|
+
private readonly now: () => number;
|
|
154
|
+
private readonly setIntervalImpl: typeof setInterval;
|
|
155
|
+
private readonly clearIntervalImpl: typeof clearInterval;
|
|
156
|
+
private sweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
157
|
+
/**
|
|
158
|
+
* Set by close(). `init()` is async and arms the retention timer only after
|
|
159
|
+
* two awaits, so a surface that is torn down while its bootstrap is still in
|
|
160
|
+
* flight would otherwise arm a timer AFTER the only thing that could clear it
|
|
161
|
+
* had already run. That is not hypothetical: registerInboxMethods() kicks
|
|
162
|
+
* init() off without awaiting it, so any shutdown inside the first tick of a
|
|
163
|
+
* daemon's life hits exactly this window.
|
|
164
|
+
*/
|
|
165
|
+
private closed = false;
|
|
166
|
+
|
|
167
|
+
constructor(
|
|
168
|
+
workingDirectory: string,
|
|
169
|
+
fileName: string = DEFAULT_STORE_FILE_NAME,
|
|
170
|
+
options: InboxCursorStoreOptions = {},
|
|
171
|
+
) {
|
|
172
|
+
this.store = new HandlerSqliteStore({
|
|
173
|
+
workingDirectory,
|
|
174
|
+
fileName: fileName || DEFAULT_STORE_FILE_NAME,
|
|
175
|
+
schema: SCHEMA,
|
|
176
|
+
});
|
|
177
|
+
this.itemTtlMs = options.itemTtlMs ?? INBOX_ITEM_TTL_MS;
|
|
178
|
+
this.itemCap = options.itemCap ?? INBOX_ITEM_CAP;
|
|
179
|
+
this.sweepIntervalMs = options.sweepIntervalMs ?? INBOX_SWEEP_INTERVAL_MS;
|
|
180
|
+
this.onSweep = options.onSweep;
|
|
181
|
+
this.onSweepError = options.onSweepError;
|
|
182
|
+
this.now = options.now ?? Date.now;
|
|
183
|
+
this.setIntervalImpl = options.setIntervalImpl ?? setInterval;
|
|
184
|
+
this.clearIntervalImpl = options.clearIntervalImpl ?? clearInterval;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
get dbPath(): string {
|
|
188
|
+
return this.store.dbPath;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Open the database, then immediately reap: recovery is exactly when stale
|
|
193
|
+
* rows from previous runs must go. The periodic timer starts afterwards so
|
|
194
|
+
* retention is not a startup-only event.
|
|
195
|
+
*/
|
|
196
|
+
async init(): Promise<void> {
|
|
197
|
+
await this.store.init();
|
|
198
|
+
await this.runSweep();
|
|
199
|
+
this.startSweepTimer();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* One retention pass: age TTL first, then the count cap over what is left.
|
|
204
|
+
* Returns counts only. Running it twice in a row reclaims nothing the second
|
|
205
|
+
* time — the pass is a function of the table's current contents.
|
|
206
|
+
*/
|
|
207
|
+
sweepRetention(): InboxSweepSummary {
|
|
208
|
+
const at = this.now();
|
|
209
|
+
const expired = this.pruneOlderThan(at - this.itemTtlMs);
|
|
210
|
+
const capped = this.enforceItemCap();
|
|
211
|
+
return { at, expired, capped, remaining: this.countItems() };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Sweep, persist if anything was reclaimed, and disclose the counts. Never throws. */
|
|
215
|
+
private async runSweep(): Promise<void> {
|
|
216
|
+
try {
|
|
217
|
+
const summary = this.sweepRetention();
|
|
218
|
+
if (summary.expired + summary.capped === 0) return;
|
|
219
|
+
await this.flush();
|
|
220
|
+
this.onSweep?.(summary);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
this.onSweepError?.(error instanceof Error ? error.message : String(error));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private startSweepTimer(): void {
|
|
227
|
+
if (this.closed || this.sweepTimer !== null || this.sweepIntervalMs <= 0) return;
|
|
228
|
+
const handle = this.setIntervalImpl(() => {
|
|
229
|
+
void this.runSweep();
|
|
230
|
+
}, this.sweepIntervalMs);
|
|
231
|
+
// Retention must never be the reason the process stays alive (Bun/Node unref).
|
|
232
|
+
(handle as unknown as { unref?: () => void }).unref?.();
|
|
233
|
+
this.sweepTimer = handle;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private stopSweepTimer(): void {
|
|
237
|
+
if (this.sweepTimer === null) return;
|
|
238
|
+
this.clearIntervalImpl(this.sweepTimer);
|
|
239
|
+
this.sweepTimer = null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Count cap: keep the newest `itemCap` rows (receivedAt DESC, id ASC — the
|
|
244
|
+
* same order listItems() uses), delete the rest. Returns rows removed.
|
|
245
|
+
*/
|
|
246
|
+
private enforceItemCap(): number {
|
|
247
|
+
const before = this.countItems();
|
|
248
|
+
if (before <= this.itemCap) return 0;
|
|
249
|
+
this.store.run(
|
|
250
|
+
`DELETE FROM items WHERE id NOT IN (
|
|
251
|
+
SELECT id FROM items ORDER BY receivedAt DESC, id ASC LIMIT ?
|
|
252
|
+
)`,
|
|
253
|
+
[this.itemCap],
|
|
254
|
+
);
|
|
255
|
+
const removed = before - this.countItems();
|
|
256
|
+
if (removed > 0) this.dirty = true;
|
|
257
|
+
return removed;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Insert/refresh items, deduping by id. On conflict the mutable feed fields
|
|
262
|
+
* (unread, previews, routeId, receivedAt) are updated in place.
|
|
263
|
+
* Returns the number of NEW (previously unseen) items.
|
|
264
|
+
*/
|
|
265
|
+
upsertItems(items: readonly InboundChannelItem[]): number {
|
|
266
|
+
if (items.length === 0) return 0;
|
|
267
|
+
let inserted = 0;
|
|
268
|
+
// Only the ids in THIS batch can collide, so probe for exactly those rather
|
|
269
|
+
// than loading the whole table — bounds the lookup to the poll size instead
|
|
270
|
+
// of growing O(n) with the (unbounded) feed.
|
|
271
|
+
const batchIds = [...new Set(items.map((i) => i.id))];
|
|
272
|
+
const placeholders = batchIds.map(() => '?').join(', ');
|
|
273
|
+
const existing = new Set(
|
|
274
|
+
this.store
|
|
275
|
+
.all<{ id: string }>(
|
|
276
|
+
`SELECT id FROM items WHERE id IN (${placeholders})`,
|
|
277
|
+
batchIds,
|
|
278
|
+
)
|
|
279
|
+
.map((r) => r.id),
|
|
280
|
+
);
|
|
281
|
+
// Track ids seen within this batch so a duplicate id in a single poll is
|
|
282
|
+
// counted (and inserted) once, not once per occurrence.
|
|
283
|
+
const seen = new Set<string>();
|
|
284
|
+
this.store.transaction(() => {
|
|
285
|
+
for (const item of items) {
|
|
286
|
+
const isNew = !existing.has(item.id) && !seen.has(item.id);
|
|
287
|
+
if (isNew) inserted += 1;
|
|
288
|
+
seen.add(item.id);
|
|
289
|
+
this.store.run(
|
|
290
|
+
`INSERT INTO items
|
|
291
|
+
(id, provider, kind, fromDigest, subjectPreview, bodyPreview,
|
|
292
|
+
routeId, receivedAt, unread)
|
|
293
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
294
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
295
|
+
provider = excluded.provider,
|
|
296
|
+
kind = excluded.kind,
|
|
297
|
+
fromDigest = excluded.fromDigest,
|
|
298
|
+
subjectPreview = excluded.subjectPreview,
|
|
299
|
+
bodyPreview = excluded.bodyPreview,
|
|
300
|
+
routeId = COALESCE(excluded.routeId, items.routeId),
|
|
301
|
+
receivedAt = excluded.receivedAt,
|
|
302
|
+
unread = excluded.unread`,
|
|
303
|
+
[
|
|
304
|
+
item.id,
|
|
305
|
+
item.provider,
|
|
306
|
+
item.kind,
|
|
307
|
+
item.fromDigest,
|
|
308
|
+
item.subjectPreview,
|
|
309
|
+
item.bodyPreview,
|
|
310
|
+
item.routeId ?? null,
|
|
311
|
+
item.receivedAt,
|
|
312
|
+
item.unread ? 1 : 0,
|
|
313
|
+
],
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
this.dirty = true;
|
|
318
|
+
return inserted;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Advance a provider's cursor monotonically. The stored value is always the
|
|
323
|
+
* max of the current value and the supplied candidate.
|
|
324
|
+
*/
|
|
325
|
+
advanceCursor(provider: string, candidate: number): void {
|
|
326
|
+
if (!Number.isFinite(candidate)) return;
|
|
327
|
+
const current = this.getCursor(provider);
|
|
328
|
+
const next = Math.max(current, Math.floor(candidate));
|
|
329
|
+
if (next === current && current !== 0) return;
|
|
330
|
+
this.store.run(
|
|
331
|
+
`INSERT INTO cursors (provider, nextSince) VALUES (?, ?)
|
|
332
|
+
ON CONFLICT(provider) DO UPDATE SET
|
|
333
|
+
nextSince = MAX(cursors.nextSince, excluded.nextSince)`,
|
|
334
|
+
[provider, next],
|
|
335
|
+
);
|
|
336
|
+
this.dirty = true;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Current cursor for a provider (0 when unset). */
|
|
340
|
+
getCursor(provider: string): number {
|
|
341
|
+
const row = this.store.get<{ nextSince: number }>(
|
|
342
|
+
'SELECT nextSince FROM cursors WHERE provider = ?',
|
|
343
|
+
[provider],
|
|
344
|
+
);
|
|
345
|
+
return row ? Number(row.nextSince) : 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Read items for the feed, filtered by provider set + since, newest first,
|
|
350
|
+
* capped at limit. Maps SQLite rows back to the internal item shape.
|
|
351
|
+
*/
|
|
352
|
+
listItems(query: InboxQuery): InboundChannelItem[] {
|
|
353
|
+
const clauses: string[] = [];
|
|
354
|
+
const params: (string | number)[] = [];
|
|
355
|
+
if (query.providers && query.providers.length > 0) {
|
|
356
|
+
const placeholders = query.providers.map(() => '?').join(', ');
|
|
357
|
+
clauses.push(`provider IN (${placeholders})`);
|
|
358
|
+
params.push(...query.providers);
|
|
359
|
+
}
|
|
360
|
+
if (typeof query.since === 'number' && Number.isFinite(query.since)) {
|
|
361
|
+
clauses.push('receivedAt > ?');
|
|
362
|
+
params.push(Math.floor(query.since));
|
|
363
|
+
}
|
|
364
|
+
if (query.after && Number.isFinite(query.after.receivedAt)) {
|
|
365
|
+
// Strictly after the cursor row in (receivedAt DESC, id ASC) order: an
|
|
366
|
+
// older timestamp, or the same timestamp with a higher id. Both halves
|
|
367
|
+
// are needed — several items can share a receivedAt, and comparing on
|
|
368
|
+
// the timestamp alone would drop every one of its ties.
|
|
369
|
+
clauses.push('(receivedAt < ? OR (receivedAt = ? AND id > ?))');
|
|
370
|
+
const at = Math.floor(query.after.receivedAt);
|
|
371
|
+
params.push(at, at, query.after.id);
|
|
372
|
+
}
|
|
373
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
374
|
+
const limit = Math.max(0, Math.floor(query.limit));
|
|
375
|
+
params.push(limit);
|
|
376
|
+
const rows = this.store.all<ItemRow>(
|
|
377
|
+
`SELECT id, provider, kind, fromDigest, subjectPreview, bodyPreview,
|
|
378
|
+
routeId, receivedAt, unread
|
|
379
|
+
FROM items ${where}
|
|
380
|
+
ORDER BY receivedAt DESC, id ASC
|
|
381
|
+
LIMIT ?`,
|
|
382
|
+
params,
|
|
383
|
+
);
|
|
384
|
+
return rows.map(rowToItem);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Highest receivedAt across the (optionally provider-filtered) feed, or 0. */
|
|
388
|
+
maxReceivedAt(providers?: readonly string[]): number {
|
|
389
|
+
const { where, params } = filterClause(providers);
|
|
390
|
+
const row = this.store.get<{ maxReceived: number | null }>(
|
|
391
|
+
`SELECT MAX(receivedAt) AS maxReceived FROM items ${where}`,
|
|
392
|
+
params,
|
|
393
|
+
);
|
|
394
|
+
return row && row.maxReceived != null ? Number(row.maxReceived) : 0;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Total count of items matching the (optional) provider set and `since`
|
|
399
|
+
* watermark.
|
|
400
|
+
*
|
|
401
|
+
* `since` is part of the filter and not an afterthought: the total is what a
|
|
402
|
+
* caller compares its page against to know whether it has seen everything, so
|
|
403
|
+
* a total counting rows the same call's `since` excluded would tell it there
|
|
404
|
+
* is more when there is not.
|
|
405
|
+
*/
|
|
406
|
+
countItems(providers?: readonly string[], since?: number): number {
|
|
407
|
+
const { where, params } = filterClause(providers, since);
|
|
408
|
+
const row = this.store.get<{ n: number }>(
|
|
409
|
+
`SELECT COUNT(*) AS n FROM items ${where}`,
|
|
410
|
+
params,
|
|
411
|
+
);
|
|
412
|
+
return row ? Number(row.n) : 0;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Per-provider item counts over the same filter, in ONE query.
|
|
417
|
+
*
|
|
418
|
+
* The aggregator reports a stored count for every provider on every call, and
|
|
419
|
+
* doing that with one countItems() per provider would be a query per provider
|
|
420
|
+
* per request. Providers absent from the result simply have no rows.
|
|
421
|
+
*/
|
|
422
|
+
countItemsByProvider(providers?: readonly string[], since?: number): Map<string, number> {
|
|
423
|
+
const { where, params } = filterClause(providers, since);
|
|
424
|
+
const rows = this.store.all<{ provider: string; n: number }>(
|
|
425
|
+
`SELECT provider, COUNT(*) AS n FROM items ${where} GROUP BY provider`,
|
|
426
|
+
params,
|
|
427
|
+
);
|
|
428
|
+
const out = new Map<string, number>();
|
|
429
|
+
for (const row of rows) out.set(row.provider, Number(row.n));
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Retention: delete items strictly older than `cutoff` (Unix ms), optionally
|
|
435
|
+
* scoped to a provider set. Returns the number of rows removed. Cursors are
|
|
436
|
+
* left untouched so already-consumed watermarks never regress. A non-finite
|
|
437
|
+
* cutoff is a no-op (prevents an accidental whole-table wipe).
|
|
438
|
+
*/
|
|
439
|
+
pruneOlderThan(cutoff: number, providers?: readonly string[]): number {
|
|
440
|
+
if (!Number.isFinite(cutoff)) return 0;
|
|
441
|
+
const clauses = ['receivedAt < ?'];
|
|
442
|
+
const params: (string | number)[] = [Math.floor(cutoff)];
|
|
443
|
+
if (providers && providers.length > 0) {
|
|
444
|
+
const placeholders = providers.map(() => '?').join(', ');
|
|
445
|
+
clauses.push(`provider IN (${placeholders})`);
|
|
446
|
+
params.push(...providers);
|
|
447
|
+
}
|
|
448
|
+
const before = this.countItems(providers);
|
|
449
|
+
this.store.run(`DELETE FROM items WHERE ${clauses.join(' AND ')}`, params);
|
|
450
|
+
const removed = before - this.countItems(providers);
|
|
451
|
+
if (removed > 0) this.dirty = true;
|
|
452
|
+
return removed;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Persist to disk if anything changed since the last flush. */
|
|
456
|
+
async flush(): Promise<void> {
|
|
457
|
+
if (!this.dirty) return;
|
|
458
|
+
await this.store.save();
|
|
459
|
+
this.dirty = false;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Stop the retention timer, flush (best-effort), then close the database. */
|
|
463
|
+
async close(): Promise<void> {
|
|
464
|
+
if (this.closed) return;
|
|
465
|
+
this.closed = true;
|
|
466
|
+
this.stopSweepTimer();
|
|
467
|
+
try {
|
|
468
|
+
await this.flush();
|
|
469
|
+
} finally {
|
|
470
|
+
this.store.close();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Shared WHERE builder for the provider-set + since filter the reads share. */
|
|
476
|
+
function filterClause(
|
|
477
|
+
providers?: readonly string[],
|
|
478
|
+
since?: number,
|
|
479
|
+
): { where: string; params: (string | number)[] } {
|
|
480
|
+
const clauses: string[] = [];
|
|
481
|
+
const params: (string | number)[] = [];
|
|
482
|
+
if (providers && providers.length > 0) {
|
|
483
|
+
clauses.push(`provider IN (${providers.map(() => '?').join(', ')})`);
|
|
484
|
+
params.push(...providers);
|
|
485
|
+
}
|
|
486
|
+
if (typeof since === 'number' && Number.isFinite(since)) {
|
|
487
|
+
clauses.push('receivedAt > ?');
|
|
488
|
+
params.push(Math.floor(since));
|
|
489
|
+
}
|
|
490
|
+
return { where: clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '', params };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function rowToItem(row: ItemRow): InboundChannelItem {
|
|
494
|
+
const item: InboundChannelItem = {
|
|
495
|
+
id: row.id,
|
|
496
|
+
provider: row.provider,
|
|
497
|
+
kind: normalizeKind(row.kind),
|
|
498
|
+
fromDigest: row.fromDigest,
|
|
499
|
+
subjectPreview: row.subjectPreview,
|
|
500
|
+
bodyPreview: row.bodyPreview,
|
|
501
|
+
receivedAt: Number(row.receivedAt),
|
|
502
|
+
unread: Number(row.unread) !== 0,
|
|
503
|
+
};
|
|
504
|
+
if (row.routeId != null) item.routeId = row.routeId;
|
|
505
|
+
return item;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function normalizeKind(value: string): InboundChannelItem['kind'] {
|
|
509
|
+
return value === 'dm' || value === 'thread' || value === 'mention' || value === 'reaction'
|
|
510
|
+
? value
|
|
511
|
+
: 'dm';
|
|
512
|
+
}
|