heyio 0.42.0 → 1.0.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.
Files changed (100) hide show
  1. package/README.md +40 -52
  2. package/dist/api/auth.js +35 -38
  3. package/dist/api/server.js +157 -1139
  4. package/dist/config.js +49 -32
  5. package/dist/copilot/agents.js +72 -1055
  6. package/dist/copilot/client.js +6 -17
  7. package/dist/copilot/io-scheduler.js +55 -139
  8. package/dist/copilot/model-router.js +100 -72
  9. package/dist/copilot/orchestrator.js +91 -515
  10. package/dist/copilot/scheduler.js +67 -189
  11. package/dist/copilot/skills.js +41 -366
  12. package/dist/copilot/system-message.js +40 -200
  13. package/dist/copilot/tools.js +191 -2042
  14. package/dist/daemon.js +54 -201
  15. package/dist/index.js +15 -133
  16. package/dist/mcp/config.js +23 -31
  17. package/dist/mcp/index.js +2 -3
  18. package/dist/mcp/registry.js +33 -88
  19. package/dist/notify.js +18 -100
  20. package/dist/paths.js +13 -24
  21. package/dist/setup.js +35 -0
  22. package/dist/store/db.js +111 -297
  23. package/dist/store/feed.js +29 -97
  24. package/dist/store/instances.js +56 -121
  25. package/dist/store/schedules.js +21 -73
  26. package/dist/store/squads.js +35 -186
  27. package/dist/store/tasks.js +25 -168
  28. package/dist/telegram/bot.js +20 -312
  29. package/dist/telegram/handlers.js +39 -3
  30. package/dist/watchdog.js +31 -45
  31. package/dist/wiki/fs.js +38 -155
  32. package/dist/wiki/search.js +31 -44
  33. package/package.json +5 -8
  34. package/web-dist/assets/ChatView-EFFiln1H.js +11 -0
  35. package/web-dist/assets/FeedView-bN4NMOL7.js +6 -0
  36. package/web-dist/assets/LoginView-CNtasq3n.js +1 -0
  37. package/web-dist/assets/McpView-C2CHiwsi.js +1 -0
  38. package/web-dist/assets/SchedulesView-CyilLban.js +1 -0
  39. package/web-dist/assets/SettingsView-1wLXKEF4.js +1 -0
  40. package/web-dist/assets/SkillsView-BLsD-0u0.js +1 -0
  41. package/web-dist/assets/SquadDetailView-CsCw2ZLp.js +21 -0
  42. package/web-dist/assets/SquadsView-DQ3vFlyO.js +6 -0
  43. package/web-dist/assets/WikiView-19M3oqnq.js +21 -0
  44. package/web-dist/assets/api-WGvTsXaE.js +1 -0
  45. package/web-dist/assets/index-D7M5O-_l.css +1 -0
  46. package/web-dist/assets/index-DZOS9syn.js +95 -0
  47. package/web-dist/assets/plus-BOvyX1BC.js +6 -0
  48. package/web-dist/assets/trash-2-DHoetkC4.js +6 -0
  49. package/web-dist/favicon.svg +4 -1
  50. package/web-dist/index.html +7 -10
  51. package/dist/api/logout.test.js +0 -129
  52. package/dist/api/mcp.test.js +0 -285
  53. package/dist/api/wiki.test.js +0 -283
  54. package/dist/auth/session-logic.js +0 -79
  55. package/dist/auth/session-logic.test.js +0 -201
  56. package/dist/copilot/auto-complete-instance.test.js +0 -104
  57. package/dist/copilot/cron.js +0 -136
  58. package/dist/copilot/event-summary.js +0 -286
  59. package/dist/copilot/instance-deactivate.test.js +0 -119
  60. package/dist/copilot/model-router.test.js +0 -71
  61. package/dist/copilot/review-backfill.js +0 -57
  62. package/dist/copilot/session-timeout.js +0 -112
  63. package/dist/copilot/session-timeout.test.js +0 -372
  64. package/dist/copilot/skills.test.js +0 -55
  65. package/dist/copilot/universes.js +0 -469
  66. package/dist/instance-watchdog.js +0 -104
  67. package/dist/instance-watchdog.test.js +0 -183
  68. package/dist/mcp/client.js +0 -109
  69. package/dist/mcp/client.test.js +0 -99
  70. package/dist/mcp/config.test.js +0 -49
  71. package/dist/mcp/registry.test.js +0 -79
  72. package/dist/notify.test.js +0 -232
  73. package/dist/store/feed.test.js +0 -279
  74. package/dist/store/instances.test.js +0 -310
  75. package/dist/store/io-schedules.js +0 -63
  76. package/dist/store/notifications.js +0 -79
  77. package/dist/store/notifications.test.js +0 -197
  78. package/dist/store/schedule-runs.js +0 -46
  79. package/dist/store/squads.test.js +0 -405
  80. package/dist/store/tasks.test.js +0 -150
  81. package/dist/store/worktrees.js +0 -83
  82. package/dist/tui/index.js +0 -286
  83. package/dist/update.js +0 -81
  84. package/dist/watchdog.test.js +0 -83
  85. package/dist/wiki/wiki-squad.test.js +0 -54
  86. package/web-dist/assets/AgentActivityView-CedxxE6K.js +0 -1
  87. package/web-dist/assets/ChatView-DMkYQo_V.js +0 -4
  88. package/web-dist/assets/FeedView-BH4q-31V.js +0 -1
  89. package/web-dist/assets/InboxView-BVwVP4EW.js +0 -1
  90. package/web-dist/assets/LoginView-DRPDhnwu.js +0 -1
  91. package/web-dist/assets/McpView-D8yWz-lq.js +0 -1
  92. package/web-dist/assets/SchedulesView-BzzyncGF.js +0 -1
  93. package/web-dist/assets/SettingsTabs.vue_vue_type_script_setup_true_lang-oW3ySu7Y.js +0 -1
  94. package/web-dist/assets/SkillsView-oxpYuhx7.js +0 -1
  95. package/web-dist/assets/SquadsView-CaKUIKlq.js +0 -1
  96. package/web-dist/assets/StatusIndicator.vue_vue_type_script_setup_true_lang-8U15Qp_Q.js +0 -1
  97. package/web-dist/assets/WikiView-C5jXUlfW.js +0 -1
  98. package/web-dist/assets/index-BrWzNw-N.css +0 -10
  99. package/web-dist/assets/index-f67odrrt.js +0 -81
  100. package/web-dist/icons.svg +0 -24
@@ -1,310 +0,0 @@
1
- /**
2
- * Tests for src/store/instances.ts — squad instances store.
3
- *
4
- * DB isolation: setDbPathForTests() redirects the SQLite singleton to a
5
- * fresh tmp file so these tests never touch ~/.io/io.db.
6
- */
7
- import { before, after, beforeEach, describe, it } from "node:test";
8
- import assert from "node:assert/strict";
9
- import { mkdtempSync, rmSync } from "node:fs";
10
- import { tmpdir } from "node:os";
11
- import { join } from "node:path";
12
- import { setDbPathForTests, closeDb, getDb } from "./db.js";
13
- import { createInstance, getInstance, listInstances, updateInstanceStatus, logInstanceDecision, getInstanceDecisions, mergeInstanceDecisions, deleteInstance, buildContextSnapshot, reconcileInstances, MAX_CONCURRENT_INSTANCES, } from "./instances.js";
14
- import { logDecision, getDecisions } from "./squads.js";
15
- // ── DB isolation ─────────────────────────────────────────────────────────────
16
- let tmpDir;
17
- before(() => {
18
- tmpDir = mkdtempSync(join(tmpdir(), "io-instances-test-"));
19
- setDbPathForTests(join(tmpDir, "io.db"));
20
- });
21
- after(() => {
22
- closeDb();
23
- rmSync(tmpDir, { recursive: true, force: true });
24
- });
25
- beforeEach(() => {
26
- const db = getDb();
27
- db.prepare("DELETE FROM instance_decisions").run();
28
- db.prepare("DELETE FROM squad_instances").run();
29
- db.prepare("DELETE FROM squad_decisions").run();
30
- db.prepare("DELETE FROM squads").run();
31
- db.prepare("INSERT INTO squads (slug, name, project_path) VALUES (?, ?, ?)").run("test-squad", "Test Squad", "/tmp/test");
32
- });
33
- // ── helpers ───────────────────────────────────────────────────────────────────
34
- function makeInstance(id, slug = "test-squad", status) {
35
- const inst = createInstance({
36
- id,
37
- masterSquadSlug: slug,
38
- issueRef: `#${id}`,
39
- worktreePath: `/tmp/nonexistent-worktree-${id}`,
40
- branchName: `${slug}/instance/${id}`,
41
- });
42
- if (status && status !== "pending") {
43
- updateInstanceStatus(id, status);
44
- }
45
- return inst;
46
- }
47
- // ── createInstance ────────────────────────────────────────────────────────────
48
- describe("createInstance", () => {
49
- it("creates and returns an instance with correct fields", () => {
50
- const inst = createInstance({
51
- id: "inst-1",
52
- masterSquadSlug: "test-squad",
53
- issueRef: "#42",
54
- worktreePath: "/tmp/wt/inst-1",
55
- branchName: "test-squad/instance/inst-1",
56
- contextSnapshot: JSON.stringify([{ decision: "use TypeScript" }]),
57
- });
58
- assert.equal(inst.id, "inst-1");
59
- assert.equal(inst.master_squad_slug, "test-squad");
60
- assert.equal(inst.issue_ref, "#42");
61
- assert.equal(inst.worktree_path, "/tmp/wt/inst-1");
62
- assert.equal(inst.branch_name, "test-squad/instance/inst-1");
63
- assert.equal(inst.status, "pending");
64
- assert.ok(inst.context_snapshot?.includes("use TypeScript"));
65
- assert.equal(inst.completed_at, null);
66
- assert.ok(inst.created_at);
67
- });
68
- it("throws when max concurrent instances are exceeded", () => {
69
- // Create MAX_CONCURRENT_INSTANCES active instances
70
- for (let i = 1; i <= MAX_CONCURRENT_INSTANCES; i++) {
71
- createInstance({
72
- id: `inst-max-${i}`,
73
- masterSquadSlug: "test-squad",
74
- worktreePath: `/tmp/wt/inst-max-${i}`,
75
- branchName: `test-squad/instance/inst-max-${i}`,
76
- });
77
- }
78
- assert.throws(() => createInstance({
79
- id: "inst-over-limit",
80
- masterSquadSlug: "test-squad",
81
- worktreePath: "/tmp/wt/inst-over-limit",
82
- branchName: "test-squad/instance/inst-over-limit",
83
- }), /Max concurrent instances/);
84
- });
85
- it("does not count done/failed instances toward the limit", () => {
86
- for (let i = 1; i <= MAX_CONCURRENT_INSTANCES; i++) {
87
- const inst = createInstance({
88
- id: `inst-done-${i}`,
89
- masterSquadSlug: "test-squad",
90
- worktreePath: `/tmp/wt/inst-done-${i}`,
91
- branchName: `test-squad/instance/inst-done-${i}`,
92
- });
93
- updateInstanceStatus(inst.id, "done");
94
- }
95
- // Should not throw — all prior instances are done
96
- assert.doesNotThrow(() => createInstance({
97
- id: "inst-after-done",
98
- masterSquadSlug: "test-squad",
99
- worktreePath: "/tmp/wt/inst-after-done",
100
- branchName: "test-squad/instance/inst-after-done",
101
- }));
102
- });
103
- });
104
- // ── getInstance ───────────────────────────────────────────────────────────────
105
- describe("getInstance", () => {
106
- it("returns undefined for non-existent ID", () => {
107
- assert.equal(getInstance("no-such-id"), undefined);
108
- });
109
- it("returns the correct instance after create", () => {
110
- makeInstance("get-test");
111
- const inst = getInstance("get-test");
112
- assert.ok(inst);
113
- assert.equal(inst.id, "get-test");
114
- });
115
- });
116
- // ── listInstances ─────────────────────────────────────────────────────────────
117
- describe("listInstances", () => {
118
- it("filters by slug and excludes done/failed by default", () => {
119
- makeInstance("li-active-1");
120
- makeInstance("li-active-2");
121
- makeInstance("li-done", "test-squad", "done");
122
- makeInstance("li-failed", "test-squad", "failed");
123
- const active = listInstances("test-squad");
124
- assert.equal(active.length, 2);
125
- assert.ok(active.every((i) => i.status === "pending"));
126
- });
127
- it("includes completed instances when opted in", () => {
128
- makeInstance("li-pending");
129
- makeInstance("li-done2", "test-squad", "done");
130
- const all = listInstances("test-squad", { includeCompleted: true });
131
- assert.equal(all.length, 2);
132
- });
133
- it("does not include instances from other squads", () => {
134
- getDb().prepare("INSERT INTO squads (slug, name, project_path) VALUES (?, ?, ?)").run("other-squad", "Other", "/tmp/other");
135
- makeInstance("li-other", "other-squad");
136
- makeInstance("li-mine");
137
- const mine = listInstances("test-squad");
138
- assert.equal(mine.length, 1);
139
- assert.equal(mine[0].id, "li-mine");
140
- });
141
- });
142
- // ── updateInstanceStatus ──────────────────────────────────────────────────────
143
- describe("updateInstanceStatus", () => {
144
- it("sets completed_at for terminal status 'done'", () => {
145
- makeInstance("upd-done");
146
- updateInstanceStatus("upd-done", "done");
147
- const inst = getInstance("upd-done");
148
- assert.equal(inst.status, "done");
149
- assert.ok(inst.completed_at !== null);
150
- });
151
- it("sets completed_at for terminal status 'failed'", () => {
152
- makeInstance("upd-failed");
153
- updateInstanceStatus("upd-failed", "failed");
154
- const inst = getInstance("upd-failed");
155
- assert.equal(inst.status, "failed");
156
- assert.ok(inst.completed_at !== null);
157
- });
158
- it("does not set completed_at for non-terminal status 'active'", () => {
159
- makeInstance("upd-active");
160
- updateInstanceStatus("upd-active", "active");
161
- const inst = getInstance("upd-active");
162
- assert.equal(inst.status, "active");
163
- assert.equal(inst.completed_at, null);
164
- });
165
- });
166
- // ── logInstanceDecision + getInstanceDecisions ────────────────────────────────
167
- describe("logInstanceDecision / getInstanceDecisions", () => {
168
- it("round-trips a decision with context", () => {
169
- makeInstance("dec-inst");
170
- logInstanceDecision("dec-inst", "use Jest for tests", "better DX");
171
- const decisions = getInstanceDecisions("dec-inst");
172
- assert.equal(decisions.length, 1);
173
- assert.equal(decisions[0].decision, "use Jest for tests");
174
- assert.equal(decisions[0].context, "better DX");
175
- assert.equal(decisions[0].merged_to_master, 0);
176
- });
177
- it("stores null context when not provided", () => {
178
- makeInstance("dec-nocontext");
179
- logInstanceDecision("dec-nocontext", "some decision");
180
- const decisions = getInstanceDecisions("dec-nocontext");
181
- assert.equal(decisions[0].context, null);
182
- });
183
- it("returns decisions in ascending created_at order", () => {
184
- makeInstance("dec-order");
185
- logInstanceDecision("dec-order", "first");
186
- logInstanceDecision("dec-order", "second");
187
- const decisions = getInstanceDecisions("dec-order");
188
- assert.equal(decisions[0].decision, "first");
189
- assert.equal(decisions[1].decision, "second");
190
- });
191
- });
192
- // ── mergeInstanceDecisions ────────────────────────────────────────────────────
193
- describe("mergeInstanceDecisions", () => {
194
- it("copies decisions to squad_decisions with provenance tag and marks merged", () => {
195
- makeInstance("merge-inst");
196
- logInstanceDecision("merge-inst", "use SQLite transactions", "performance");
197
- logInstanceDecision("merge-inst", "append-only log", "conflict-free");
198
- const count = mergeInstanceDecisions("merge-inst", "test-squad");
199
- assert.equal(count, 2);
200
- const masterDecisions = getDecisions("test-squad");
201
- assert.equal(masterDecisions.length, 2);
202
- assert.ok(masterDecisions.some((d) => d.decision === "use SQLite transactions"));
203
- assert.ok(masterDecisions.every((d) => d.context?.includes("[from instance: merge-inst]")));
204
- const instDecisions = getInstanceDecisions("merge-inst");
205
- assert.ok(instDecisions.every((d) => d.merged_to_master === 1));
206
- });
207
- it("is idempotent — calling twice does not double-merge", () => {
208
- makeInstance("merge-idempotent");
209
- logInstanceDecision("merge-idempotent", "idempotent decision");
210
- mergeInstanceDecisions("merge-idempotent", "test-squad");
211
- const second = mergeInstanceDecisions("merge-idempotent", "test-squad");
212
- assert.equal(second, 0);
213
- const masterDecisions = getDecisions("test-squad");
214
- assert.equal(masterDecisions.length, 1);
215
- });
216
- it("returns 0 when there are no decisions to merge", () => {
217
- makeInstance("merge-empty");
218
- const count = mergeInstanceDecisions("merge-empty", "test-squad");
219
- assert.equal(count, 0);
220
- });
221
- });
222
- // ── deleteInstance ────────────────────────────────────────────────────────────
223
- describe("deleteInstance", () => {
224
- it("removes the instance and its decisions", () => {
225
- makeInstance("del-inst");
226
- logInstanceDecision("del-inst", "a decision");
227
- deleteInstance("del-inst");
228
- assert.equal(getInstance("del-inst"), undefined);
229
- assert.deepEqual(getInstanceDecisions("del-inst"), []);
230
- });
231
- it("is safe to call on a non-existent id", () => {
232
- assert.doesNotThrow(() => deleteInstance("no-such-instance"));
233
- });
234
- });
235
- // ── buildContextSnapshot ──────────────────────────────────────────────────────
236
- describe("buildContextSnapshot", () => {
237
- it("returns a JSON object with decisions array", () => {
238
- logDecision("test-squad", "use TypeScript everywhere", "consistency");
239
- logDecision("test-squad", "prefer functional style");
240
- const snapshot = buildContextSnapshot("test-squad");
241
- const parsed = JSON.parse(snapshot);
242
- assert.ok(Array.isArray(parsed.decisions));
243
- assert.equal(parsed.decisions.length, 2);
244
- assert.ok(parsed.decisions.some((d) => d.decision === "use TypeScript everywhere"));
245
- });
246
- it("returns empty decisions array for a squad with no decisions", () => {
247
- const snapshot = buildContextSnapshot("test-squad");
248
- const parsed = JSON.parse(snapshot);
249
- assert.deepEqual(parsed.decisions, []);
250
- });
251
- it("respects the limit parameter", () => {
252
- for (let i = 0; i < 10; i++) {
253
- logDecision("test-squad", `decision ${i}`);
254
- }
255
- const snapshot = buildContextSnapshot("test-squad", 5);
256
- const parsed = JSON.parse(snapshot);
257
- assert.equal(parsed.decisions.length, 5);
258
- });
259
- it("includes wiki pages when they exist", async () => {
260
- const { writePage, deletePage } = await import("../wiki/fs.js");
261
- const testSlug = `test-squad-snap-${Date.now()}`;
262
- const pagePath = `pages/squads/${testSlug}/rules.md`;
263
- try {
264
- writePage(pagePath, "# Rules\nNo force push.");
265
- logDecision(testSlug, "test decision");
266
- const snapshot = buildContextSnapshot(testSlug);
267
- const parsed = JSON.parse(snapshot);
268
- assert.ok(parsed.wiki);
269
- assert.equal(parsed.wiki.length, 1);
270
- assert.equal(parsed.wiki[0].path, pagePath);
271
- assert.ok(parsed.wiki[0].content.includes("No force push"));
272
- }
273
- finally {
274
- deletePage(pagePath);
275
- }
276
- });
277
- });
278
- // ── reconcileInstances ────────────────────────────────────────────────────────
279
- describe("reconcileInstances", () => {
280
- it("marks pending/active/merging instances failed when worktree does not exist", () => {
281
- // These worktree paths do not exist on disk
282
- makeInstance("recon-pending"); // status: pending
283
- makeInstance("recon-active", "test-squad", "active"); // status: active
284
- makeInstance("recon-merging", "test-squad", "merging"); // status: merging
285
- const cleaned = reconcileInstances();
286
- assert.equal(cleaned, 3);
287
- assert.equal(getInstance("recon-pending").status, "failed");
288
- assert.equal(getInstance("recon-active").status, "failed");
289
- assert.equal(getInstance("recon-merging").status, "failed");
290
- });
291
- it("does not touch already-terminal instances", () => {
292
- makeInstance("recon-done", "test-squad", "done");
293
- makeInstance("recon-failed", "test-squad", "failed");
294
- const cleaned = reconcileInstances();
295
- assert.equal(cleaned, 0);
296
- });
297
- it("returns 0 when all non-terminal instances have valid worktrees", () => {
298
- // Use a path that actually exists (tmpDir was created above)
299
- createInstance({
300
- id: "recon-exists",
301
- masterSquadSlug: "test-squad",
302
- worktreePath: tmpDir, // this path exists on disk
303
- branchName: "test-squad/instance/recon-exists",
304
- });
305
- const cleaned = reconcileInstances();
306
- assert.equal(cleaned, 0);
307
- assert.equal(getInstance("recon-exists").status, "pending");
308
- });
309
- });
310
- //# sourceMappingURL=instances.test.js.map
@@ -1,63 +0,0 @@
1
- import { getDb } from "./db.js";
2
- export function createIoSchedule(input) {
3
- const db = getDb();
4
- const info = db
5
- .prepare(`INSERT INTO io_schedules
6
- (name, cron_expr, prompt, notes, enabled, next_run_at)
7
- VALUES (?, ?, ?, ?, 1, ?)`)
8
- .run(input.name, input.cronExpr, input.prompt, input.notes ?? null, input.nextRunAt);
9
- const id = Number(info.lastInsertRowid);
10
- return getIoSchedule(id);
11
- }
12
- export function getIoSchedule(id) {
13
- return getDb()
14
- .prepare("SELECT * FROM io_schedules WHERE id = ?")
15
- .get(id);
16
- }
17
- export function listIoSchedules() {
18
- return getDb()
19
- .prepare("SELECT * FROM io_schedules ORDER BY id ASC")
20
- .all();
21
- }
22
- export function listDueIoSchedules(now) {
23
- return getDb()
24
- .prepare(`SELECT * FROM io_schedules
25
- WHERE enabled = 1
26
- AND next_run_at IS NOT NULL
27
- AND next_run_at <= ?
28
- ORDER BY next_run_at ASC`)
29
- .all(now.toISOString());
30
- }
31
- export function deleteIoSchedule(id) {
32
- const info = getDb()
33
- .prepare("DELETE FROM io_schedules WHERE id = ?")
34
- .run(id);
35
- return info.changes > 0;
36
- }
37
- export function setIoScheduleEnabled(id, enabled) {
38
- const info = getDb()
39
- .prepare("UPDATE io_schedules SET enabled = ? WHERE id = ?")
40
- .run(enabled ? 1 : 0, id);
41
- return info.changes > 0;
42
- }
43
- export function recordIoScheduleRun(id, ranAt, nextRunAt) {
44
- getDb()
45
- .prepare("UPDATE io_schedules SET last_run_at = ?, next_run_at = ? WHERE id = ?")
46
- .run(ranAt.toISOString(), nextRunAt, id);
47
- }
48
- export function updateIoScheduleNextRun(id, nextRunAt) {
49
- getDb()
50
- .prepare("UPDATE io_schedules SET next_run_at = ? WHERE id = ?")
51
- .run(nextRunAt, id);
52
- }
53
- /**
54
- * Overwrite both last_run_at and next_run_at directly. Unlike
55
- * recordIoScheduleRun this accepts NULL for last_run_at, which is needed when
56
- * restoring a schedule's "never run" state after a manual run_now.
57
- */
58
- export function setIoScheduleTimestamps(id, lastRunAt, nextRunAt) {
59
- getDb()
60
- .prepare("UPDATE io_schedules SET last_run_at = ?, next_run_at = ? WHERE id = ?")
61
- .run(lastRunAt, nextRunAt, id);
62
- }
63
- //# sourceMappingURL=io-schedules.js.map
@@ -1,79 +0,0 @@
1
- import { getDb } from "./db.js";
2
- /**
3
- * Insert a new background notification. Returns the inserted row including
4
- * the autoincrement id and DB-assigned created_at timestamp. source_ref
5
- * should be a JSON string or null.
6
- */
7
- export function insertNotification(input) {
8
- const db = getDb();
9
- const info = db
10
- .prepare(`INSERT INTO background_notifications (source_type, source_ref, title, text)
11
- VALUES (?, ?, ?, ?)`)
12
- .run(input.source_type, input.source_ref, input.title, input.text);
13
- return db
14
- .prepare("SELECT * FROM background_notifications WHERE id = ?")
15
- .get(info.lastInsertRowid);
16
- }
17
- /**
18
- * List the most recent notifications, newest first. Default limit 50.
19
- */
20
- export function listRecentNotifications(limit = 50) {
21
- return getDb()
22
- .prepare("SELECT * FROM background_notifications ORDER BY created_at DESC, id DESC LIMIT ?")
23
- .all(limit);
24
- }
25
- /**
26
- * List unread notifications (read_at IS NULL), newest first.
27
- */
28
- export function listUnreadNotifications() {
29
- return getDb()
30
- .prepare("SELECT * FROM background_notifications WHERE read_at IS NULL ORDER BY created_at DESC, id DESC")
31
- .all();
32
- }
33
- /**
34
- * Count unread notifications. Cheap — uses COUNT(*).
35
- */
36
- export function countUnreadNotifications() {
37
- const row = getDb()
38
- .prepare("SELECT COUNT(*) AS n FROM background_notifications WHERE read_at IS NULL")
39
- .get();
40
- return row.n;
41
- }
42
- /**
43
- * Mark a single notification read. Returns true if the row exists (whether
44
- * it was already read or just now marked), false if no such id exists.
45
- */
46
- export function markNotificationRead(id) {
47
- const db = getDb();
48
- const info = db
49
- .prepare("UPDATE background_notifications SET read_at = CURRENT_TIMESTAMP WHERE id = ? AND read_at IS NULL")
50
- .run(id);
51
- if (info.changes > 0)
52
- return true;
53
- // Already read — verify the row exists at all
54
- const exists = db
55
- .prepare("SELECT id FROM background_notifications WHERE id = ?")
56
- .get(id);
57
- return exists !== undefined;
58
- }
59
- /**
60
- * Mark every unread notification read. Returns the number of rows affected.
61
- */
62
- export function markAllNotificationsRead() {
63
- const info = getDb()
64
- .prepare("UPDATE background_notifications SET read_at = CURRENT_TIMESTAMP WHERE read_at IS NULL")
65
- .run();
66
- return info.changes;
67
- }
68
- /**
69
- * Delete notifications older than `olderThanDays` days. Returns rows deleted.
70
- * Used by a future retention sweep.
71
- */
72
- export function pruneOldNotifications(olderThanDays) {
73
- const info = getDb()
74
- .prepare(`DELETE FROM background_notifications
75
- WHERE created_at < datetime('now', ? || ' days')`)
76
- .run(`-${olderThanDays}`);
77
- return info.changes;
78
- }
79
- //# sourceMappingURL=notifications.js.map
@@ -1,197 +0,0 @@
1
- /**
2
- * Tests for src/store/notifications.ts — SQLite CRUD helpers.
3
- *
4
- * DB isolation: setDbPathForTests() redirects the SQLite singleton to a
5
- * fresh tmp file, ensuring these tests never touch ~/.io/io.db.
6
- */
7
- import { before, after, beforeEach, describe, it } from "node:test";
8
- import assert from "node:assert/strict";
9
- import { mkdtempSync, rmSync } from "node:fs";
10
- import { tmpdir } from "node:os";
11
- import { join } from "node:path";
12
- import { setDbPathForTests, closeDb, getDb } from "./db.js";
13
- import { insertNotification, listRecentNotifications, listUnreadNotifications, countUnreadNotifications, markNotificationRead, markAllNotificationsRead, pruneOldNotifications, } from "./notifications.js";
14
- // ── DB isolation ────────────────────────────────────────────────────────────
15
- let tmpDir;
16
- before(() => {
17
- tmpDir = mkdtempSync(join(tmpdir(), "io-notifs-test-"));
18
- setDbPathForTests(join(tmpDir, "io.db"));
19
- });
20
- // Wipe all notifications between tests for a clean slate.
21
- beforeEach(() => {
22
- getDb().prepare("DELETE FROM background_notifications").run();
23
- });
24
- after(() => {
25
- closeDb();
26
- rmSync(tmpDir, { recursive: true, force: true });
27
- });
28
- // ── Helpers ─────────────────────────────────────────────────────────────────
29
- function makeNotif(overrides = {}) {
30
- return insertNotification({
31
- source_type: "io-schedule",
32
- source_ref: JSON.stringify({ scheduleId: 1 }),
33
- title: "Test Notification",
34
- text: "This is the notification body.",
35
- ...overrides,
36
- });
37
- }
38
- // ── insertNotification ───────────────────────────────────────────────────────
39
- describe("insertNotification", () => {
40
- it("returns a row with autoincrement id and created_at timestamp", () => {
41
- const row = makeNotif();
42
- assert.ok(typeof row.id === "number" && row.id > 0, "id should be a positive integer");
43
- assert.ok(row.created_at, "created_at should be set");
44
- assert.equal(row.title, "Test Notification");
45
- assert.equal(row.text, "This is the notification body.");
46
- assert.equal(row.read_at, null);
47
- });
48
- it("ids are autoincremented across inserts", () => {
49
- const a = makeNotif();
50
- const b = makeNotif();
51
- assert.ok(b.id > a.id, "second id should be greater than first");
52
- });
53
- it("accepts source_ref: null", () => {
54
- const row = makeNotif({ source_ref: null });
55
- assert.equal(row.source_ref, null);
56
- });
57
- it("stores source_type correctly", () => {
58
- const row = makeNotif({ source_type: "squad-schedule" });
59
- assert.equal(row.source_type, "squad-schedule");
60
- });
61
- });
62
- // ── listRecentNotifications ──────────────────────────────────────────────────
63
- describe("listRecentNotifications", () => {
64
- it("returns newest first", () => {
65
- const a = makeNotif({ title: "First" });
66
- const b = makeNotif({ title: "Second" });
67
- const c = makeNotif({ title: "Third" });
68
- const rows = listRecentNotifications();
69
- assert.equal(rows[0].id, c.id, "newest should be first");
70
- assert.equal(rows[rows.length - 1].id, a.id, "oldest should be last");
71
- });
72
- it("default limit is 50", () => {
73
- for (let i = 0; i < 55; i++)
74
- makeNotif({ title: `N${i}` });
75
- const rows = listRecentNotifications();
76
- assert.equal(rows.length, 50);
77
- });
78
- it("explicit limit is honored", () => {
79
- for (let i = 0; i < 10; i++)
80
- makeNotif({ title: `N${i}` });
81
- const rows = listRecentNotifications(3);
82
- assert.equal(rows.length, 3);
83
- });
84
- });
85
- // ── countUnreadNotifications ─────────────────────────────────────────────────
86
- describe("countUnreadNotifications", () => {
87
- it("starts at zero on a clean DB", () => {
88
- assert.equal(countUnreadNotifications(), 0);
89
- });
90
- it("increases on insert", () => {
91
- makeNotif();
92
- assert.equal(countUnreadNotifications(), 1);
93
- makeNotif();
94
- assert.equal(countUnreadNotifications(), 2);
95
- });
96
- it("decreases when a notification is marked read", () => {
97
- const a = makeNotif();
98
- makeNotif();
99
- assert.equal(countUnreadNotifications(), 2);
100
- markNotificationRead(a.id);
101
- assert.equal(countUnreadNotifications(), 1);
102
- });
103
- });
104
- // ── markNotificationRead ─────────────────────────────────────────────────────
105
- describe("markNotificationRead", () => {
106
- it("returns false on a non-existent id", () => {
107
- assert.equal(markNotificationRead(999999), false);
108
- });
109
- it("returns true on an existing unread notification", () => {
110
- const row = makeNotif();
111
- assert.equal(markNotificationRead(row.id), true);
112
- });
113
- it("is idempotent — returns true even if already read", () => {
114
- const row = makeNotif();
115
- assert.equal(markNotificationRead(row.id), true);
116
- assert.equal(markNotificationRead(row.id), true, "second call should still return true");
117
- });
118
- it("sets read_at on the row", () => {
119
- const row = makeNotif();
120
- markNotificationRead(row.id);
121
- const updated = getDb()
122
- .prepare("SELECT read_at FROM background_notifications WHERE id = ?")
123
- .get(row.id);
124
- assert.ok(updated.read_at, "read_at should be set after marking read");
125
- });
126
- });
127
- // ── markAllNotificationsRead ─────────────────────────────────────────────────
128
- describe("markAllNotificationsRead", () => {
129
- it("returns the count of newly-marked rows", () => {
130
- makeNotif();
131
- makeNotif();
132
- makeNotif();
133
- assert.equal(markAllNotificationsRead(), 3);
134
- });
135
- it("subsequent call returns 0 (all already read)", () => {
136
- makeNotif();
137
- makeNotif();
138
- markAllNotificationsRead();
139
- assert.equal(markAllNotificationsRead(), 0);
140
- });
141
- it("only marks unread rows — pre-read rows not re-touched", () => {
142
- const a = makeNotif();
143
- makeNotif();
144
- markNotificationRead(a.id); // mark one manually first
145
- const count = markAllNotificationsRead(); // should only mark the remaining 1
146
- assert.equal(count, 1);
147
- });
148
- });
149
- // ── listUnreadNotifications ──────────────────────────────────────────────────
150
- describe("listUnreadNotifications", () => {
151
- it("excludes read rows", () => {
152
- const a = makeNotif({ title: "A" });
153
- const b = makeNotif({ title: "B" });
154
- markNotificationRead(a.id);
155
- const unread = listUnreadNotifications();
156
- assert.ok(!unread.some((r) => r.id === a.id), "read row should not appear");
157
- assert.ok(unread.some((r) => r.id === b.id), "unread row should appear");
158
- });
159
- it("returns newest first", () => {
160
- const a = makeNotif({ title: "A" });
161
- const b = makeNotif({ title: "B" });
162
- const rows = listUnreadNotifications();
163
- assert.equal(rows[0].id, b.id);
164
- assert.equal(rows[1].id, a.id);
165
- });
166
- it("returns empty array when all are read", () => {
167
- makeNotif();
168
- makeNotif();
169
- markAllNotificationsRead();
170
- assert.deepEqual(listUnreadNotifications(), []);
171
- });
172
- });
173
- // ── pruneOldNotifications ─────────────────────────────────────────────────────
174
- describe("pruneOldNotifications", () => {
175
- it("deletes rows older than the threshold and returns the count", () => {
176
- const old = makeNotif({ title: "Old" });
177
- makeNotif({ title: "Recent" }); // stays untouched
178
- // Back-date the 'old' row to 10 days ago
179
- getDb()
180
- .prepare("UPDATE background_notifications SET created_at = datetime('now', '-10 days') WHERE id = ?")
181
- .run(old.id);
182
- const deleted = pruneOldNotifications(7); // prune rows older than 7 days
183
- assert.equal(deleted, 1, "should delete exactly the back-dated row");
184
- const remaining = listRecentNotifications();
185
- assert.ok(!remaining.some((r) => r.id === old.id), "old row should be gone");
186
- assert.ok(remaining.some((r) => r.title === "Recent"), "recent row should remain");
187
- });
188
- it("returns 0 when nothing is old enough to prune", () => {
189
- makeNotif();
190
- makeNotif();
191
- assert.equal(pruneOldNotifications(7), 0);
192
- });
193
- it("is safe on an empty table", () => {
194
- assert.equal(pruneOldNotifications(1), 0);
195
- });
196
- });
197
- //# sourceMappingURL=notifications.test.js.map