@danypops/tickets 0.12.0 → 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/tickets",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Unified CLI, daemon, and TypeScript library for issue tracking across GitHub, GitLab, and Jira.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,8 +24,8 @@
24
24
  "typecheck": "tsc --noEmit"
25
25
  },
26
26
  "dependencies": {
27
- "@danypops/vehicle-core": "^0.13.0",
28
- "@danypops/vehicle-server": "^0.18.4",
27
+ "@danypops/vehicle-core": "^0.15.0",
28
+ "@danypops/vehicle-server": "^0.21.0",
29
29
  "@danypops/vehicle-client": "^0.7.1",
30
30
  "@danypops/enigma-client": "^0.6.1",
31
31
  "@gitbeaker/rest": "^43.8.0",
@@ -25,7 +25,7 @@ import {
25
25
  import { VehicleRegistry } from "@danypops/vehicle-server";
26
26
  import type { BackendCapabilities, TicketService } from "../issue/service.js";
27
27
  import type { TicketOperation } from "../rpc/ops.js";
28
- import { TICKET_OP_HANDLERS, type TicketsAppDeps } from "../rpc/server.js";
28
+ import { type HandlerCallContext, TICKET_OP_HANDLERS, type TicketsAppDeps } from "../rpc/server.js";
29
29
  import { withTicketsErrorParity } from "./error-mapping.js";
30
30
 
31
31
  const OWNER = "tickets";
@@ -265,6 +265,61 @@ const OPERATIONS: readonly OperationSpec[] = [
265
265
  properties: { name: stringProp, limit: numberProp },
266
266
  required: ["name"],
267
267
  },
268
+ {
269
+ action: "issue.subscribe",
270
+ description:
271
+ "Has the daemon keep watching one issue in the background -- comments, status, and other field changes are reported through watch_events, no manual re-checking needed. Idempotent. subscriberId scopes this watch to one caller (defaults to this calling Pi session's own real session id, then a shared anonymous subscriber for a raw RPC client with no session at all); scheduleMs bounds how often that subscriber's watch is refreshed, in milliseconds (omit to refresh on every background sync tick). projectRoot attributes this subscription to a project -- defaults to this calling session's own cwd; rarely needs to be passed explicitly.",
272
+ effect: "local-write",
273
+ properties: { ref: stringProp, subscriberId: stringProp, scheduleMs: numberProp, projectRoot: stringProp },
274
+ required: ["ref"],
275
+ },
276
+ {
277
+ action: "issue.unsubscribe",
278
+ description:
279
+ "Stops the daemon watching one issue in the background. Idempotent -- no error if it wasn't subscribed. subscriberId removes only that one caller's watch, leaving any other subscriber's own watch on the same issue intact.",
280
+ effect: "local-write",
281
+ properties: { ref: stringProp, subscriberId: stringProp },
282
+ required: ["ref"],
283
+ },
284
+ {
285
+ action: "issue.subscribed",
286
+ description:
287
+ "Every issue this subscriber is currently watching -- never a live backend call, cheap to call frequently. subscriberId defaults to this calling Pi session's own real session id.",
288
+ effect: "read",
289
+ properties: { subscriberId: stringProp },
290
+ required: [],
291
+ },
292
+ {
293
+ action: "query.subscribe",
294
+ description:
295
+ "Has the daemon keep re-running one saved query in the background -- new matching items or items that drop out are reported through watch_events, no manual re-checking needed. Idempotent. subscriberId/scheduleMs/projectRoot behave exactly like issue.subscribe's own.",
296
+ effect: "local-write",
297
+ properties: { name: stringProp, subscriberId: stringProp, scheduleMs: numberProp, projectRoot: stringProp },
298
+ required: ["name"],
299
+ },
300
+ {
301
+ action: "query.unsubscribe",
302
+ description: "Stops the daemon re-running one saved query in the background. Idempotent -- no error if it wasn't subscribed.",
303
+ effect: "local-write",
304
+ properties: { name: stringProp, subscriberId: stringProp },
305
+ required: ["name"],
306
+ },
307
+ {
308
+ action: "query.subscribed",
309
+ description:
310
+ "Every saved query this subscriber is currently watching -- never a live backend call, cheap to call frequently. subscriberId defaults to this calling Pi session's own real session id.",
311
+ effect: "read",
312
+ properties: { subscriberId: stringProp },
313
+ required: [],
314
+ },
315
+ {
316
+ action: "watch.events",
317
+ description:
318
+ "New change events (comments, status, label/field changes, saved-query membership changes) for everything this subscriber currently watches, since sinceId -- cheaper than re-fetching every watched issue/query yourself. Pass the previous call's lastId as sinceId to page forward; omit it once to start from 'now' without replaying history.",
319
+ effect: "read",
320
+ properties: { subscriberId: stringProp, sinceId: numberProp, limit: numberProp },
321
+ required: [],
322
+ },
268
323
  {
269
324
  action: "stage.add",
270
325
  description:
@@ -419,7 +474,17 @@ export function createTicketsVehicleRegistry(deps: Omit<TicketsAppDeps, "vehicle
419
474
  bindVehicleOperation(
420
475
  operation,
421
476
  () => async (context) =>
422
- withTicketsErrorParity<unknown>(() => handler(deps, mapInput(context.input as Record<string, unknown>) as never)),
477
+ withTicketsErrorParity<unknown>(() => {
478
+ // Threaded through unconditionally -- harmless for the vast majority of handlers that
479
+ // never read it; issue.subscribe/query.subscribe (and their siblings) use it to default
480
+ // subscriberId/projectRoot from this real call's own session identity, mirroring
481
+ // @danypops/pipes' own ci.subscribe. See HandlerCallContext's own doc comment.
482
+ const callContext: HandlerCallContext = {
483
+ callerSessionId: context.callerSessionId,
484
+ callerProjectRoot: context.callerProjectRoot,
485
+ };
486
+ return handler(deps, mapInput(context.input as Record<string, unknown>) as never, callContext);
487
+ }),
423
488
  ),
424
489
  );
425
490
  }
package/src/cli/index.ts CHANGED
@@ -158,6 +158,37 @@ program
158
158
  await withClient((client) => client.call("issue.merge", { ref, method: opts.method }));
159
159
  });
160
160
 
161
+ program
162
+ .command("subscribe <ref>")
163
+ .description("watch one issue in the background -- comments, status, and other field changes are reported via `watch-events`")
164
+ .option("--schedule-ms <ms>", "minimum check cadence for this subscription, in milliseconds", (v) => Number.parseInt(v, 10))
165
+ .action(async (ref: string, opts) => {
166
+ await withClient((client) => client.call("issue.subscribe", { ref, scheduleMs: opts.scheduleMs }));
167
+ });
168
+
169
+ program
170
+ .command("unsubscribe <ref>")
171
+ .description("stop watching one issue")
172
+ .action(async (ref: string) => {
173
+ await withClient((client) => client.call("issue.unsubscribe", { ref }));
174
+ });
175
+
176
+ program
177
+ .command("subscribed")
178
+ .description("list every issue you're currently watching")
179
+ .action(async () => {
180
+ await withClient((client) => client.call("issue.subscribed", {}));
181
+ });
182
+
183
+ program
184
+ .command("watch-events")
185
+ .description("new change events for everything you're currently watching (issues and saved queries), since --since-id")
186
+ .option("--since-id <id>", "only events after this event id", (v) => Number.parseInt(v, 10))
187
+ .option("--limit <n>", "max events", (v) => Number.parseInt(v, 10))
188
+ .action(async (opts) => {
189
+ await withClient((client) => client.call("watch.events", { sinceId: opts.sinceId, limit: opts.limit }));
190
+ });
191
+
161
192
  const comment = program.command("comment").description("comment operations");
162
193
 
163
194
  comment
@@ -321,6 +352,28 @@ queryCmd
321
352
  await withClient((client) => client.call("query.run", { name, limit: opts.limit }));
322
353
  });
323
354
 
355
+ queryCmd
356
+ .command("subscribe <name>")
357
+ .description("watch one saved query in the background -- new/dropped items are reported via `watch-events`")
358
+ .option("--schedule-ms <ms>", "minimum check cadence for this subscription, in milliseconds", (v) => Number.parseInt(v, 10))
359
+ .action(async (name: string, opts) => {
360
+ await withClient((client) => client.call("query.subscribe", { name, scheduleMs: opts.scheduleMs }));
361
+ });
362
+
363
+ queryCmd
364
+ .command("unsubscribe <name>")
365
+ .description("stop watching one saved query")
366
+ .action(async (name: string) => {
367
+ await withClient((client) => client.call("query.unsubscribe", { name }));
368
+ });
369
+
370
+ queryCmd
371
+ .command("subscribed")
372
+ .description("list every saved query you're currently watching")
373
+ .action(async () => {
374
+ await withClient((client) => client.call("query.subscribed", {}));
375
+ });
376
+
324
377
  discoverCmd
325
378
  .command("board_quickfilter")
326
379
  .description(
@@ -19,8 +19,10 @@ import { buildApp, type TicketsAppDeps } from "../rpc/server.js";
19
19
  import { FOCUS_MIGRATIONS, FocusStore } from "../sqlite/focus.js";
20
20
  import { LEDGER_MIGRATIONS, Ledger } from "../sqlite/ledger.js";
21
21
  import { SAVED_QUERY_MIGRATIONS, SavedQueryStore } from "../sqlite/saved-queries.js";
22
+ import { WATCH_MIGRATIONS, WatchStore } from "../sqlite/watches.js";
22
23
  import { StageStore } from "../stage/store.js";
23
24
  import { createSyncTask } from "./poller.js";
25
+ import { createIssueWatchSyncTask, createQueryWatchSyncTask } from "./watch-sync.js";
24
26
 
25
27
  export interface BootstrapOptions {
26
28
  pathEnv?: PathEnvironment;
@@ -39,6 +41,10 @@ export interface BootstrapOptions {
39
41
  checkpointIntervalMs?: number;
40
42
  /** How often the live backend set re-resolves from config/env/Enigma. Ignored when repos is injected. */
41
43
  backendRefreshIntervalMs?: number;
44
+ /** How often every subscribed issue is re-fetched and diffed. Defaults to DEFAULT_ISSUE_WATCH_INTERVAL_MS. */
45
+ issueWatchIntervalMs?: number;
46
+ /** How often every subscribed saved query is re-run and diffed. Defaults to DEFAULT_QUERY_WATCH_INTERVAL_MS. */
47
+ queryWatchIntervalMs?: number;
42
48
  /**
43
49
  * Overrides the daemon.shutdown op's effect. Defaults to sending this
44
50
  * process SIGTERM, which vehicle-server's runDaemonProcess already handles
@@ -54,6 +60,7 @@ export interface BootstrappedDaemon {
54
60
  focusStore: FocusStore;
55
61
  queries: SavedQueryStore;
56
62
  stageStore: StageStore;
63
+ watches: WatchStore;
57
64
  service: TicketService;
58
65
  options: StartDaemonOptions;
59
66
  }
@@ -61,15 +68,23 @@ export interface BootstrappedDaemon {
61
68
  const DEFAULT_SYNC_INTERVAL_MS = 5 * 60_000;
62
69
  const DEFAULT_CHECKPOINT_INTERVAL_MS = 10 * 60_000;
63
70
  const DEFAULT_BACKEND_REFRESH_INTERVAL_MS = 30_000;
71
+ /** Deliberately coarser than pipes' own 30s RUN_POOL_SYNC_INTERVAL_MS -- a CI run's status changes
72
+ * on the order of seconds/minutes; an issue's comments/status change on the order of minutes/hours,
73
+ * so polling that fast would only waste API quota against GitHub/GitLab/Jira's own rate limits. */
74
+ const DEFAULT_ISSUE_WATCH_INTERVAL_MS = 60_000;
75
+ const DEFAULT_QUERY_WATCH_INTERVAL_MS = 60_000;
64
76
 
65
77
  export async function bootstrap(opts: BootstrapOptions = {}): Promise<BootstrappedDaemon> {
66
78
  const paths = resolveDaemonPaths(TICKETS_DAEMON_NAMES, opts.pathEnv);
67
79
  const token = ensureAuthToken(paths.token, "Tickets");
68
- const db = openSqliteWithPragmas(paths.database, { migrations: [...LEDGER_MIGRATIONS, ...FOCUS_MIGRATIONS, ...SAVED_QUERY_MIGRATIONS] });
80
+ const db = openSqliteWithPragmas(paths.database, {
81
+ migrations: [...LEDGER_MIGRATIONS, ...FOCUS_MIGRATIONS, ...SAVED_QUERY_MIGRATIONS, ...WATCH_MIGRATIONS],
82
+ });
69
83
  const ledger = new Ledger(db);
70
84
  const focusStore = new FocusStore(db);
71
85
  const queries = new SavedQueryStore(db);
72
86
  const stageStore = new StageStore();
87
+ const watches = new WatchStore(db);
73
88
  const logger = opts.logger ?? createLogger("tickets-daemon", { levelEnvVar: "TICKETS_LOG_LEVEL" });
74
89
  const config = opts.config ?? loadConfig();
75
90
  const buildRepos = opts.buildRepositories ?? buildRepositories;
@@ -88,6 +103,7 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
88
103
  focusStore,
89
104
  queries,
90
105
  stageStore,
106
+ watches,
91
107
  token,
92
108
  version,
93
109
  logger,
@@ -100,6 +116,8 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
100
116
  logger,
101
117
  maintenanceTasks: [
102
118
  createSyncTask(service, ledger, opts.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS, logger),
119
+ createIssueWatchSyncTask(service, watches, opts.issueWatchIntervalMs ?? DEFAULT_ISSUE_WATCH_INTERVAL_MS, logger),
120
+ createQueryWatchSyncTask(service, queries, watches, opts.queryWatchIntervalMs ?? DEFAULT_QUERY_WATCH_INTERVAL_MS, logger),
103
121
  {
104
122
  name: "checkpoint",
105
123
  intervalMs: opts.checkpointIntervalMs ?? DEFAULT_CHECKPOINT_INTERVAL_MS,
@@ -127,6 +145,7 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
127
145
  focusStore,
128
146
  queries,
129
147
  stageStore,
148
+ watches,
130
149
  token,
131
150
  version,
132
151
  logger,
@@ -138,5 +157,5 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
138
157
  },
139
158
  };
140
159
 
141
- return { db, ledger, focusStore, queries, stageStore, service, options };
160
+ return { db, ledger, focusStore, queries, stageStore, watches, service, options };
142
161
  }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Watch sync — the tickets daemon's own analog of @danypops/pipes' run/monitor.ts's syncRunPool,
3
+ * for individual issues and saved queries instead of CI jobs. Two independent maintenance tasks
4
+ * (createIssueWatchSyncTask / createQueryWatchSyncTask, wired in process/bootstrap.ts), same
5
+ * shape as pipes': read the current subscription list fresh from SQLite every tick, group by key
6
+ * (so N subscribers on the same ref/query share one live fetch), skip anything not yet due per its
7
+ * own scheduleMs, fetch once per due group, diff against the last-cached snapshot, and only
8
+ * persist a WatchEvent (via WatchStore.recordEvent) on a real, human-describable change.
9
+ *
10
+ * Deliberately never unsubscribes anything on its own (see watches.ts's own doc comment) -- a
11
+ * failed fetch for one key is logged and skipped, retried next tick, exactly like pipes' own
12
+ * per-group isolation.
13
+ */
14
+ import type { MaintenanceTask } from "@danypops/vehicle-server/daemon";
15
+ import type { Logger } from "@danypops/vehicle-server/logging";
16
+ import type { TicketService } from "../issue/service.js";
17
+ import type { SavedQueryStore } from "../sqlite/saved-queries.js";
18
+ import type { IssueWatchSubscription, QueryWatchSubscription, WatchStore } from "../sqlite/watches.js";
19
+
20
+ const NOOP_LOGGER: Logger = { debug() {}, info() {}, warn() {}, error() {} };
21
+
22
+ const DEFAULT_QUERY_WATCH_LIMIT = 50;
23
+
24
+ /** True if this subscription's own cadence has elapsed since it was last checked -- always true for a subscription with no scheduleMs, matching pipes' own isDue. */
25
+ function isDue(subscription: { scheduleMs?: number; lastCheckedAt?: Date }, nowMs: number): boolean {
26
+ if (subscription.scheduleMs === undefined) return true;
27
+ if (subscription.lastCheckedAt === undefined) return true;
28
+ return nowMs - subscription.lastCheckedAt.getTime() >= subscription.scheduleMs;
29
+ }
30
+
31
+ function groupBy<T, K>(items: T[], key: (item: T) => K): Map<K, T[]> {
32
+ const groups = new Map<K, T[]>();
33
+ for (const item of items) {
34
+ const k = key(item);
35
+ const existing = groups.get(k);
36
+ if (existing) existing.push(item);
37
+ else groups.set(k, [item]);
38
+ }
39
+ return groups;
40
+ }
41
+
42
+ /**
43
+ * Best-effort comment count: undefined (never diffed on) for a backend that doesn't support
44
+ * comments at all (NotSupportedError) or a transient failure fetching them -- a missing comment
45
+ * count must never itself look like "0 comments" and falsely report "N new comments" once support
46
+ * (or connectivity) returns.
47
+ */
48
+ async function tryCommentCount(service: TicketService, ref: string): Promise<number | undefined> {
49
+ try {
50
+ return (await service.comments(ref)).length;
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ /** Human-readable diffs between two issue snapshots, most specific first. Empty means "no real, describable change" even if fetched_at moved. */
57
+ function diffIssueSnapshot(
58
+ previous: { status: string; updatedAt?: string; commentCount?: number } | undefined,
59
+ current: { status: string; updatedAt?: string; commentCount?: number },
60
+ ): string[] {
61
+ if (!previous) return [];
62
+ const changes: string[] = [];
63
+ if (current.status !== previous.status) changes.push(`status: ${previous.status} -> ${current.status}`);
64
+ if (current.commentCount !== undefined && previous.commentCount !== undefined && current.commentCount > previous.commentCount) {
65
+ const added = current.commentCount - previous.commentCount;
66
+ changes.push(`${added} new comment${added === 1 ? "" : "s"}`);
67
+ }
68
+ // A generic fallback for a backend-reported update this diff can't further characterize (a field
69
+ // edit, a label/assignee change, ...) -- only surfaced when nothing more specific already explains
70
+ // it, so a status change never also reports a redundant "updated".
71
+ if (changes.length === 0 && current.updatedAt !== undefined && current.updatedAt !== previous.updatedAt) {
72
+ changes.push("updated");
73
+ }
74
+ return changes;
75
+ }
76
+
77
+ export interface IssueWatchChange {
78
+ ref: string;
79
+ changes: string[];
80
+ }
81
+
82
+ /** One tick: fetches every due watched issue once (deduped across subscribers), diffs, and records a WatchEvent per real change. */
83
+ export async function syncIssueWatches(
84
+ service: TicketService,
85
+ watches: WatchStore,
86
+ logger: Logger = NOOP_LOGGER,
87
+ onChange?: (change: IssueWatchChange) => void,
88
+ now: () => number = Date.now,
89
+ ): Promise<void> {
90
+ const groups = groupBy(watches.issueSubscriptions(), (s: IssueWatchSubscription) => s.ref);
91
+ const nowMs = now();
92
+ const due = [...groups.entries()].filter(([, subs]) => subs.some((s) => isDue(s, nowMs)));
93
+
94
+ await Promise.all(
95
+ due.map(async ([ref, subs]) => {
96
+ try {
97
+ const issue = await service.get(ref);
98
+ const commentCount = await tryCommentCount(service, ref);
99
+ const previous = watches.getIssueSnapshot(ref);
100
+ const fetchedAt = new Date(nowMs);
101
+ watches.upsertIssueSnapshot({ ref, status: issue.status, updatedAt: issue.updatedAt, commentCount: commentCount ?? 0, fetchedAt });
102
+
103
+ const changes = diffIssueSnapshot(
104
+ previous ? { status: previous.status, updatedAt: previous.updatedAt, commentCount: previous.commentCount } : undefined,
105
+ { status: issue.status, updatedAt: issue.updatedAt, commentCount },
106
+ );
107
+ if (changes.length > 0) {
108
+ const message = `${ref} (${issue.title}): ${changes.join(", ")}`;
109
+ watches.recordEvent("issue", ref, message, fetchedAt);
110
+ onChange?.({ ref, changes });
111
+ }
112
+ for (const subscription of subs) watches.markIssueChecked(ref, subscription.subscriberId, fetchedAt);
113
+ } catch (error) {
114
+ logger.warn("issue watch sync failed for one ref", { ref, error: error instanceof Error ? error.message : String(error) });
115
+ }
116
+ }),
117
+ );
118
+ }
119
+
120
+ export interface QueryWatchChange {
121
+ name: string;
122
+ added: string[];
123
+ removed: string[];
124
+ }
125
+
126
+ /** One tick: re-runs every due watched saved query once, diffs the *set* of matching refs against last time, and records a WatchEvent when items appeared or dropped out. */
127
+ export async function syncQueryWatches(
128
+ service: TicketService,
129
+ queries: SavedQueryStore,
130
+ watches: WatchStore,
131
+ logger: Logger = NOOP_LOGGER,
132
+ onChange?: (change: QueryWatchChange) => void,
133
+ now: () => number = Date.now,
134
+ ): Promise<void> {
135
+ const groups = groupBy(watches.queryWatchSubscriptions(), (s: QueryWatchSubscription) => s.name);
136
+ const nowMs = now();
137
+ const due = [...groups.entries()].filter(([, subs]) => subs.some((s) => isDue(s, nowMs)));
138
+
139
+ await Promise.all(
140
+ due.map(async ([name, subs]) => {
141
+ try {
142
+ const saved = queries.get(name);
143
+ if (!saved) {
144
+ logger.warn("watched saved query no longer exists", { name });
145
+ return;
146
+ }
147
+ const issues = await service.runQuery(saved.backend, saved.query, DEFAULT_QUERY_WATCH_LIMIT);
148
+ const refs = issues.map((issue) => issue.ref);
149
+ const previous = watches.getQuerySnapshot(name);
150
+ const fetchedAt = new Date(nowMs);
151
+ watches.upsertQuerySnapshot({ name, refs, fetchedAt });
152
+
153
+ if (previous) {
154
+ const previousSet = new Set(previous.refs);
155
+ const currentSet = new Set(refs);
156
+ const added = refs.filter((ref) => !previousSet.has(ref));
157
+ const removed = previous.refs.filter((ref) => !currentSet.has(ref));
158
+ if (added.length > 0 || removed.length > 0) {
159
+ const parts: string[] = [];
160
+ if (added.length > 0) parts.push(`${added.length} new: ${added.join(", ")}`);
161
+ if (removed.length > 0) parts.push(`${removed.length} dropped out: ${removed.join(", ")}`);
162
+ const message = `"${name}": ${parts.join("; ")}`;
163
+ watches.recordEvent("query", name, message, fetchedAt);
164
+ onChange?.({ name, added, removed });
165
+ }
166
+ }
167
+ for (const subscription of subs) watches.markQueryChecked(name, subscription.subscriberId, fetchedAt);
168
+ } catch (error) {
169
+ logger.warn("query watch sync failed for one saved query", { name, error: error instanceof Error ? error.message : String(error) });
170
+ }
171
+ }),
172
+ );
173
+ }
174
+
175
+ /** MaintenanceTask wrapper for syncIssueWatches -- reads watches.issueSubscriptions() fresh every tick, so an empty watch list means this tick does no live fetches at all (see this module's own doc comment). */
176
+ export function createIssueWatchSyncTask(
177
+ service: TicketService,
178
+ watches: WatchStore,
179
+ intervalMs: number,
180
+ logger?: Logger,
181
+ onChange?: (change: IssueWatchChange) => void,
182
+ ): MaintenanceTask {
183
+ return {
184
+ name: "issue-watch-sync",
185
+ intervalMs,
186
+ run: () => syncIssueWatches(service, watches, logger, onChange),
187
+ };
188
+ }
189
+
190
+ /** MaintenanceTask wrapper for syncQueryWatches -- same empty-watch-list-is-a-no-op shape as createIssueWatchSyncTask. */
191
+ export function createQueryWatchSyncTask(
192
+ service: TicketService,
193
+ queries: SavedQueryStore,
194
+ watches: WatchStore,
195
+ intervalMs: number,
196
+ logger?: Logger,
197
+ onChange?: (change: QueryWatchChange) => void,
198
+ ): MaintenanceTask {
199
+ return {
200
+ name: "query-watch-sync",
201
+ intervalMs,
202
+ run: () => syncQueryWatches(service, queries, watches, logger, onChange),
203
+ };
204
+ }
package/src/rpc/ops.ts CHANGED
@@ -9,6 +9,7 @@ import type { BackendCapabilities } from "../issue/service.js";
9
9
  import type { Template } from "../issue/template.js";
10
10
  import type { TicketFocusState } from "../sqlite/focus.js";
11
11
  import type { SavedQuery } from "../sqlite/saved-queries.js";
12
+ import type { IssueWatchSubscription, QueryWatchSubscription, WatchEvent } from "../sqlite/watches.js";
12
13
  import type { StagedItem, StagePatchFields, StagePayload } from "../stage/store.js";
13
14
 
14
15
  export type TicketOperation =
@@ -40,6 +41,13 @@ export type TicketOperation =
40
41
  | "query.list"
41
42
  | "query.remove"
42
43
  | "query.run"
44
+ | "issue.subscribe"
45
+ | "issue.unsubscribe"
46
+ | "issue.subscribed"
47
+ | "query.subscribe"
48
+ | "query.unsubscribe"
49
+ | "query.subscribed"
50
+ | "watch.events"
43
51
  | "stage.add"
44
52
  | "stage.list"
45
53
  | "stage.show"
@@ -77,6 +85,13 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
77
85
  "query.list": Record<string, never>;
78
86
  "query.remove": { name: string };
79
87
  "query.run": { name: string; limit?: number };
88
+ "issue.subscribe": { ref: string; subscriberId?: string; scheduleMs?: number; projectRoot?: string };
89
+ "issue.unsubscribe": { ref: string; subscriberId?: string };
90
+ "issue.subscribed": { subscriberId?: string };
91
+ "query.subscribe": { name: string; subscriberId?: string; scheduleMs?: number; projectRoot?: string };
92
+ "query.unsubscribe": { name: string; subscriberId?: string };
93
+ "query.subscribed": { subscriberId?: string };
94
+ "watch.events": { subscriberId?: string; sinceId?: number; limit?: number };
80
95
  "stage.add": { payload: StagePayload };
81
96
  "stage.list": Record<string, never>;
82
97
  "stage.show": { id: string };
@@ -118,6 +133,13 @@ export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
118
133
  "query.list": { queries: SavedQuery[] };
119
134
  "query.remove": { removed: boolean };
120
135
  "query.run": { issues: Issue[] };
136
+ "issue.subscribe": { subscribed: true };
137
+ "issue.unsubscribe": { unsubscribed: true };
138
+ "issue.subscribed": { watches: IssueWatchSubscription[] };
139
+ "query.subscribe": { subscribed: true };
140
+ "query.unsubscribe": { unsubscribed: true };
141
+ "query.subscribed": { watches: QueryWatchSubscription[] };
142
+ "watch.events": { events: WatchEvent[]; lastId: number };
121
143
  "stage.add": { item: StagedItem };
122
144
  "stage.list": { items: StagedItem[] };
123
145
  "stage.show": { item: StagedItem };
@@ -156,6 +178,13 @@ export const TICKET_OPERATIONS: TicketOperation[] = [
156
178
  "query.list",
157
179
  "query.remove",
158
180
  "query.run",
181
+ "issue.subscribe",
182
+ "issue.unsubscribe",
183
+ "issue.subscribed",
184
+ "query.subscribe",
185
+ "query.unsubscribe",
186
+ "query.subscribed",
187
+ "watch.events",
159
188
  "stage.add",
160
189
  "stage.list",
161
190
  "stage.show",
package/src/rpc/server.ts CHANGED
@@ -16,6 +16,7 @@ import type { TicketService } from "../issue/service.js";
16
16
  import { FocusError, type FocusStore } from "../sqlite/focus.js";
17
17
  import type { Ledger } from "../sqlite/ledger.js";
18
18
  import { SavedQueryNotFoundError, type SavedQueryStore } from "../sqlite/saved-queries.js";
19
+ import type { WatchStore } from "../sqlite/watches.js";
19
20
  import type { StagePayload, StageStore } from "../stage/store.js";
20
21
  import { statusForKnownTicketError } from "./error-status.js";
21
22
  import { type StagePushResult, TICKET_OPERATIONS, type TicketOperation, type TicketOpInputs, type TicketOpOutputs } from "./ops.js";
@@ -45,6 +46,20 @@ export interface TicketsAppDeps {
45
46
  */
46
47
  vehicleRegistry: VehicleRegistry;
47
48
  stageStore: StageStore;
49
+ watches: WatchStore;
50
+ }
51
+
52
+ /**
53
+ * The subset of a Vehicle call's own identity a handful of handlers read for a sensible default --
54
+ * see issue.subscribe/query.subscribe below, which default subscriberId/projectRoot from these
55
+ * when the caller doesn't pass them explicitly, mirroring @danypops/pipes' own ci.subscribe.
56
+ * Always undefined-safe: absent entirely for the raw HTTP /api/v1/ops dispatch (which has no Pi
57
+ * session behind it), present when the call came through agent-tools/tickets-vehicle.ts's
58
+ * Vehicle registration (see its own VehicleOperationContext.callerSessionId/callerProjectRoot).
59
+ */
60
+ export interface HandlerCallContext {
61
+ callerSessionId?: string;
62
+ callerProjectRoot?: string;
48
63
  }
49
64
 
50
65
  // Narrower than TicketsAppDeps on purpose: no real handler reads
@@ -53,6 +68,7 @@ export interface TicketsAppDeps {
53
68
  export type Handler<Op extends TicketOperation> = (
54
69
  deps: Omit<TicketsAppDeps, "vehicleRegistry">,
55
70
  input: TicketOpInputs[Op],
71
+ callContext?: HandlerCallContext,
56
72
  ) => Promise<TicketOpOutputs[Op]>;
57
73
 
58
74
  /**
@@ -109,6 +125,37 @@ export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
109
125
  if (!saved) throw new SavedQueryNotFoundError(input.name);
110
126
  return { issues: await deps.service.runQuery(saved.backend, saved.query, input.limit) };
111
127
  },
128
+ "issue.subscribe": async (deps, input, callContext) => {
129
+ const subscriberId = input.subscriberId ?? callContext?.callerSessionId ?? "";
130
+ const projectRoot = input.projectRoot ?? callContext?.callerProjectRoot;
131
+ deps.watches.subscribeIssue(input.ref, { subscriberId, scheduleMs: input.scheduleMs, projectRoot });
132
+ return { subscribed: true };
133
+ },
134
+ "issue.unsubscribe": async (deps, input, callContext) => {
135
+ deps.watches.unsubscribeIssue(input.ref, input.subscriberId ?? callContext?.callerSessionId ?? "");
136
+ return { unsubscribed: true };
137
+ },
138
+ "issue.subscribed": async (deps, input, callContext) => ({
139
+ watches: deps.watches.issueSubscriptionsFor(input.subscriberId ?? callContext?.callerSessionId ?? ""),
140
+ }),
141
+ "query.subscribe": async (deps, input, callContext) => {
142
+ const subscriberId = input.subscriberId ?? callContext?.callerSessionId ?? "";
143
+ const projectRoot = input.projectRoot ?? callContext?.callerProjectRoot;
144
+ deps.watches.subscribeQuery(input.name, { subscriberId, scheduleMs: input.scheduleMs, projectRoot });
145
+ return { subscribed: true };
146
+ },
147
+ "query.unsubscribe": async (deps, input, callContext) => {
148
+ deps.watches.unsubscribeQuery(input.name, input.subscriberId ?? callContext?.callerSessionId ?? "");
149
+ return { unsubscribed: true };
150
+ },
151
+ "query.subscribed": async (deps, input, callContext) => ({
152
+ watches: deps.watches.queryWatchSubscriptionsFor(input.subscriberId ?? callContext?.callerSessionId ?? ""),
153
+ }),
154
+ "watch.events": async (deps, input, callContext) => {
155
+ const subscriberId = input.subscriberId ?? callContext?.callerSessionId ?? "";
156
+ const events = deps.watches.eventsSince(subscriberId, input.sinceId ?? 0, input.limit);
157
+ return { events, lastId: events.at(-1)?.id ?? input.sinceId ?? 0 };
158
+ },
112
159
  "stage.add": async (deps, input) => ({ item: deps.stageStore.add(input.payload) }),
113
160
  "stage.list": async (deps) => ({ items: deps.stageStore.list() }),
114
161
  "stage.show": async (deps, input) => ({ item: deps.stageStore.show(input.id) }),
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Watches — the daemon's local subscription + last-known-snapshot store for individual issues
3
+ * and saved queries, mirroring @danypops/pipes' own job_watches/run_snapshots split
4
+ * (packages/pipes/src/sqlite/run-pool.ts) one domain over: `issue_watches`/`query_watches` are
5
+ * the authoritative subscription lists the background sync tasks (process/watch-sync.ts) iterate;
6
+ * `issue_watch_snapshots`/`query_watch_snapshots` hold each watched key's last-observed state,
7
+ * independent of subscriptions, so a sync tick can tell "did this actually change" apart from
8
+ * "this is the first time we've ever looked." `watch_events` is the append-only, cursor-readable
9
+ * change log a client polls (watch.events) instead of re-deriving a diff itself.
10
+ *
11
+ * Deliberately does NOT auto-unsubscribe on any kind of "terminal" state the way run_snapshots
12
+ * does for a finished CI run: an issue can be reopened and a saved query's result set has no
13
+ * notion of "done" at all, so nothing here is a permanent completion signal worth stopping a
14
+ * background poll over. A subscription only ever ends via an explicit issue.unsubscribe/
15
+ * query.unsubscribe call.
16
+ */
17
+ import type { Database } from "bun:sqlite";
18
+ import type { Migration } from "@danypops/vehicle-server/storage";
19
+
20
+ export const WATCH_MIGRATIONS: Migration[] = [
21
+ {
22
+ version: 4,
23
+ up: (db) => {
24
+ db.exec(`
25
+ CREATE TABLE issue_watches (
26
+ ref TEXT NOT NULL,
27
+ subscriber_id TEXT NOT NULL DEFAULT '',
28
+ schedule_ms INTEGER,
29
+ last_checked_at INTEGER,
30
+ project_root TEXT,
31
+ PRIMARY KEY (ref, subscriber_id)
32
+ );
33
+ CREATE TABLE issue_watch_snapshots (
34
+ ref TEXT PRIMARY KEY,
35
+ status TEXT NOT NULL,
36
+ updated_at TEXT,
37
+ comment_count INTEGER NOT NULL DEFAULT 0,
38
+ fetched_at INTEGER NOT NULL
39
+ );
40
+ CREATE TABLE query_watches (
41
+ name TEXT NOT NULL,
42
+ subscriber_id TEXT NOT NULL DEFAULT '',
43
+ schedule_ms INTEGER,
44
+ last_checked_at INTEGER,
45
+ project_root TEXT,
46
+ PRIMARY KEY (name, subscriber_id)
47
+ );
48
+ CREATE TABLE query_watch_snapshots (
49
+ name TEXT PRIMARY KEY,
50
+ refs_json TEXT NOT NULL,
51
+ fetched_at INTEGER NOT NULL
52
+ );
53
+ CREATE TABLE watch_events (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ kind TEXT NOT NULL,
56
+ key TEXT NOT NULL,
57
+ message TEXT NOT NULL,
58
+ created_at INTEGER NOT NULL
59
+ );
60
+ CREATE INDEX watch_events_kind_key_idx ON watch_events(kind, key);
61
+ `);
62
+ },
63
+ },
64
+ ];
65
+
66
+ export interface IssueWatchSubscription {
67
+ ref: string;
68
+ subscriberId: string;
69
+ scheduleMs?: number;
70
+ lastCheckedAt?: Date;
71
+ projectRoot?: string;
72
+ }
73
+
74
+ export interface QueryWatchSubscription {
75
+ name: string;
76
+ subscriberId: string;
77
+ scheduleMs?: number;
78
+ lastCheckedAt?: Date;
79
+ projectRoot?: string;
80
+ }
81
+
82
+ export interface IssueWatchSnapshot {
83
+ ref: string;
84
+ status: string;
85
+ updatedAt?: string;
86
+ commentCount: number;
87
+ fetchedAt: Date;
88
+ }
89
+
90
+ export interface QueryWatchSnapshot {
91
+ name: string;
92
+ refs: string[];
93
+ fetchedAt: Date;
94
+ }
95
+
96
+ export type WatchEventKind = "issue" | "query";
97
+
98
+ export interface WatchEvent {
99
+ id: number;
100
+ kind: WatchEventKind;
101
+ /** The watched issue's ref, or the watched query's name. */
102
+ key: string;
103
+ message: string;
104
+ createdAt: Date;
105
+ }
106
+
107
+ interface IssueWatchRow {
108
+ ref: string;
109
+ subscriber_id: string;
110
+ schedule_ms: number | null;
111
+ last_checked_at: number | null;
112
+ project_root: string | null;
113
+ }
114
+
115
+ interface QueryWatchRow {
116
+ name: string;
117
+ subscriber_id: string;
118
+ schedule_ms: number | null;
119
+ last_checked_at: number | null;
120
+ project_root: string | null;
121
+ }
122
+
123
+ function toIssueSubscription(row: IssueWatchRow): IssueWatchSubscription {
124
+ return {
125
+ ref: row.ref,
126
+ subscriberId: row.subscriber_id,
127
+ scheduleMs: row.schedule_ms ?? undefined,
128
+ lastCheckedAt: row.last_checked_at !== null ? new Date(row.last_checked_at) : undefined,
129
+ projectRoot: row.project_root ?? undefined,
130
+ };
131
+ }
132
+
133
+ function toQuerySubscription(row: QueryWatchRow): QueryWatchSubscription {
134
+ return {
135
+ name: row.name,
136
+ subscriberId: row.subscriber_id,
137
+ scheduleMs: row.schedule_ms ?? undefined,
138
+ lastCheckedAt: row.last_checked_at !== null ? new Date(row.last_checked_at) : undefined,
139
+ projectRoot: row.project_root ?? undefined,
140
+ };
141
+ }
142
+
143
+ export class WatchStore {
144
+ constructor(private readonly db: Database) {}
145
+
146
+ // ---- issue watches ----
147
+
148
+ subscribeIssue(ref: string, options?: { subscriberId?: string; scheduleMs?: number; projectRoot?: string }): void {
149
+ const subscriberId = options?.subscriberId ?? "";
150
+ this.db
151
+ .query(
152
+ `INSERT INTO issue_watches (ref, subscriber_id, schedule_ms, project_root)
153
+ VALUES ($ref, $subscriberId, $scheduleMs, $projectRoot)
154
+ ON CONFLICT(ref, subscriber_id) DO UPDATE SET schedule_ms = excluded.schedule_ms, project_root = excluded.project_root`,
155
+ )
156
+ .run({
157
+ $ref: ref,
158
+ $subscriberId: subscriberId,
159
+ $scheduleMs: options?.scheduleMs ?? null,
160
+ $projectRoot: options?.projectRoot ?? null,
161
+ });
162
+ }
163
+
164
+ unsubscribeIssue(ref: string, subscriberId = ""): void {
165
+ this.db
166
+ .query("DELETE FROM issue_watches WHERE ref = $ref AND subscriber_id = $subscriberId")
167
+ .run({ $ref: ref, $subscriberId: subscriberId });
168
+ }
169
+
170
+ isIssueSubscribed(ref: string, subscriberId = ""): boolean {
171
+ return (
172
+ this.db.query("SELECT 1 FROM issue_watches WHERE ref = $ref AND subscriber_id = $subscriberId").get({
173
+ $ref: ref,
174
+ $subscriberId: subscriberId,
175
+ }) !== null
176
+ );
177
+ }
178
+
179
+ /** Every individual issue subscription -- what the sync task iterates. */
180
+ issueSubscriptions(): IssueWatchSubscription[] {
181
+ const rows = this.db.query("SELECT * FROM issue_watches").all() as IssueWatchRow[];
182
+ return rows.map(toIssueSubscription);
183
+ }
184
+
185
+ /** Subscriptions scoped to one subscriber -- what issue.subscribed returns. */
186
+ issueSubscriptionsFor(subscriberId: string): IssueWatchSubscription[] {
187
+ const rows = this.db.query("SELECT * FROM issue_watches WHERE subscriber_id = $subscriberId").all({
188
+ $subscriberId: subscriberId,
189
+ }) as IssueWatchRow[];
190
+ return rows.map(toIssueSubscription);
191
+ }
192
+
193
+ markIssueChecked(ref: string, subscriberId: string, at: Date): void {
194
+ this.db
195
+ .query("UPDATE issue_watches SET last_checked_at = $at WHERE ref = $ref AND subscriber_id = $subscriberId")
196
+ .run({ $at: at.getTime(), $ref: ref, $subscriberId: subscriberId });
197
+ }
198
+
199
+ getIssueSnapshot(ref: string): IssueWatchSnapshot | undefined {
200
+ const row = this.db.query("SELECT * FROM issue_watch_snapshots WHERE ref = $ref").get({ $ref: ref }) as {
201
+ ref: string;
202
+ status: string;
203
+ updated_at: string | null;
204
+ comment_count: number;
205
+ fetched_at: number;
206
+ } | null;
207
+ if (!row) return undefined;
208
+ return {
209
+ ref: row.ref,
210
+ status: row.status,
211
+ updatedAt: row.updated_at ?? undefined,
212
+ commentCount: row.comment_count,
213
+ fetchedAt: new Date(row.fetched_at),
214
+ };
215
+ }
216
+
217
+ upsertIssueSnapshot(snapshot: IssueWatchSnapshot): void {
218
+ this.db
219
+ .query(
220
+ `INSERT INTO issue_watch_snapshots (ref, status, updated_at, comment_count, fetched_at)
221
+ VALUES ($ref, $status, $updatedAt, $commentCount, $fetchedAt)
222
+ ON CONFLICT(ref) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at, comment_count = excluded.comment_count, fetched_at = excluded.fetched_at`,
223
+ )
224
+ .run({
225
+ $ref: snapshot.ref,
226
+ $status: snapshot.status,
227
+ $updatedAt: snapshot.updatedAt ?? null,
228
+ $commentCount: snapshot.commentCount,
229
+ $fetchedAt: snapshot.fetchedAt.getTime(),
230
+ });
231
+ }
232
+
233
+ // ---- query watches ----
234
+
235
+ subscribeQuery(name: string, options?: { subscriberId?: string; scheduleMs?: number; projectRoot?: string }): void {
236
+ const subscriberId = options?.subscriberId ?? "";
237
+ this.db
238
+ .query(
239
+ `INSERT INTO query_watches (name, subscriber_id, schedule_ms, project_root)
240
+ VALUES ($name, $subscriberId, $scheduleMs, $projectRoot)
241
+ ON CONFLICT(name, subscriber_id) DO UPDATE SET schedule_ms = excluded.schedule_ms, project_root = excluded.project_root`,
242
+ )
243
+ .run({
244
+ $name: name,
245
+ $subscriberId: subscriberId,
246
+ $scheduleMs: options?.scheduleMs ?? null,
247
+ $projectRoot: options?.projectRoot ?? null,
248
+ });
249
+ }
250
+
251
+ unsubscribeQuery(name: string, subscriberId = ""): void {
252
+ this.db
253
+ .query("DELETE FROM query_watches WHERE name = $name AND subscriber_id = $subscriberId")
254
+ .run({ $name: name, $subscriberId: subscriberId });
255
+ }
256
+
257
+ isQuerySubscribed(name: string, subscriberId = ""): boolean {
258
+ return (
259
+ this.db.query("SELECT 1 FROM query_watches WHERE name = $name AND subscriber_id = $subscriberId").get({
260
+ $name: name,
261
+ $subscriberId: subscriberId,
262
+ }) !== null
263
+ );
264
+ }
265
+
266
+ queryWatchSubscriptions(): QueryWatchSubscription[] {
267
+ const rows = this.db.query("SELECT * FROM query_watches").all() as QueryWatchRow[];
268
+ return rows.map(toQuerySubscription);
269
+ }
270
+
271
+ queryWatchSubscriptionsFor(subscriberId: string): QueryWatchSubscription[] {
272
+ const rows = this.db.query("SELECT * FROM query_watches WHERE subscriber_id = $subscriberId").all({
273
+ $subscriberId: subscriberId,
274
+ }) as QueryWatchRow[];
275
+ return rows.map(toQuerySubscription);
276
+ }
277
+
278
+ markQueryChecked(name: string, subscriberId: string, at: Date): void {
279
+ this.db
280
+ .query("UPDATE query_watches SET last_checked_at = $at WHERE name = $name AND subscriber_id = $subscriberId")
281
+ .run({ $at: at.getTime(), $name: name, $subscriberId: subscriberId });
282
+ }
283
+
284
+ getQuerySnapshot(name: string): QueryWatchSnapshot | undefined {
285
+ const row = this.db.query("SELECT * FROM query_watch_snapshots WHERE name = $name").get({ $name: name }) as {
286
+ name: string;
287
+ refs_json: string;
288
+ fetched_at: number;
289
+ } | null;
290
+ if (!row) return undefined;
291
+ return { name: row.name, refs: JSON.parse(row.refs_json) as string[], fetchedAt: new Date(row.fetched_at) };
292
+ }
293
+
294
+ upsertQuerySnapshot(snapshot: QueryWatchSnapshot): void {
295
+ this.db
296
+ .query(
297
+ `INSERT INTO query_watch_snapshots (name, refs_json, fetched_at)
298
+ VALUES ($name, $refsJson, $fetchedAt)
299
+ ON CONFLICT(name) DO UPDATE SET refs_json = excluded.refs_json, fetched_at = excluded.fetched_at`,
300
+ )
301
+ .run({ $name: snapshot.name, $refsJson: JSON.stringify(snapshot.refs), $fetchedAt: snapshot.fetchedAt.getTime() });
302
+ }
303
+
304
+ // ---- change events ----
305
+
306
+ /** Appends one change event -- called only by the sync tasks, once per real diff. */
307
+ recordEvent(kind: WatchEventKind, key: string, message: string, at: Date = new Date()): void {
308
+ this.db
309
+ .query("INSERT INTO watch_events (kind, key, message, created_at) VALUES ($kind, $key, $message, $createdAt)")
310
+ .run({ $kind: kind, $key: key, $message: message, $createdAt: at.getTime() });
311
+ }
312
+
313
+ /**
314
+ * Events since `sinceId` (exclusive), newest-last, bounded by `limit`, scoped to keys the given
315
+ * subscriber is *currently* subscribed to (an EXISTS join against issue_watches/query_watches --
316
+ * same scoping shape run-pool.ts's watchedRunsWithProjectLabels already uses for subscriberId).
317
+ * An event for a key this subscriber never subscribed to, or already unsubscribed from, never
318
+ * appears here -- avoids a global firehose leaking one session's watches into another's.
319
+ */
320
+ eventsSince(subscriberId: string, sinceId: number, limit = 100): WatchEvent[] {
321
+ const bounded = Math.max(1, Math.min(500, Math.floor(limit)));
322
+ const rows = this.db
323
+ .query(
324
+ `SELECT * FROM watch_events
325
+ WHERE id > $sinceId
326
+ AND (
327
+ (kind = 'issue' AND EXISTS (SELECT 1 FROM issue_watches WHERE issue_watches.ref = watch_events.key AND issue_watches.subscriber_id = $subscriberId))
328
+ OR
329
+ (kind = 'query' AND EXISTS (SELECT 1 FROM query_watches WHERE query_watches.name = watch_events.key AND query_watches.subscriber_id = $subscriberId))
330
+ )
331
+ ORDER BY id ASC
332
+ LIMIT $limit`,
333
+ )
334
+ .all({ $sinceId: sinceId, $subscriberId: subscriberId, $limit: bounded }) as Array<{
335
+ id: number;
336
+ kind: string;
337
+ key: string;
338
+ message: string;
339
+ created_at: number;
340
+ }>;
341
+ return rows.map((row) => ({
342
+ id: row.id,
343
+ kind: row.kind as WatchEventKind,
344
+ key: row.key,
345
+ message: row.message,
346
+ createdAt: new Date(row.created_at),
347
+ }));
348
+ }
349
+
350
+ /** The highest event id recorded so far, or 0 if none -- lets a fresh subscriber start its cursor at "now" instead of replaying every historical event. */
351
+ latestEventId(): number {
352
+ const row = this.db.query("SELECT MAX(id) as max_id FROM watch_events").get() as { max_id: number | null } | null;
353
+ return row?.max_id ?? 0;
354
+ }
355
+ }