@danypops/tickets 0.12.0 → 0.14.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.
@@ -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
+ }