reviewzy 0.1.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 (46) hide show
  1. package/.env.example +30 -0
  2. package/LICENSE +661 -0
  3. package/README.md +170 -0
  4. package/bin/reviewzy +7 -0
  5. package/package.json +29 -0
  6. package/src/config.ts +76 -0
  7. package/src/daemon/app.ts +159 -0
  8. package/src/daemon/main.ts +103 -0
  9. package/src/daemon/origin.ts +60 -0
  10. package/src/dashboard/auth.ts +122 -0
  11. package/src/dashboard/mount.ts +373 -0
  12. package/src/dashboard/static/components.css +1116 -0
  13. package/src/dashboard/static/dashboard.css +182 -0
  14. package/src/dashboard/static/dashboard.js +169 -0
  15. package/src/dashboard/static/fonts/OFL-JetBrainsMono.txt +93 -0
  16. package/src/dashboard/static/fonts/OFL-Onest.txt +93 -0
  17. package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
  18. package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
  19. package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
  20. package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
  21. package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
  22. package/src/dashboard/static/htmx.min.js +2 -0
  23. package/src/dashboard/static/tokens.css +213 -0
  24. package/src/dashboard/views.tsx +1388 -0
  25. package/src/db/human-save.ts +242 -0
  26. package/src/db/migrate.ts +71 -0
  27. package/src/db/migrations.ts +149 -0
  28. package/src/db/queries.ts +425 -0
  29. package/src/db/store.ts +83 -0
  30. package/src/db/style-guide.ts +236 -0
  31. package/src/db/sweep.ts +88 -0
  32. package/src/mcp/await-approved.ts +156 -0
  33. package/src/mcp/fetch-approved.ts +132 -0
  34. package/src/mcp/file-entries.ts +219 -0
  35. package/src/mcp/list-entries.ts +107 -0
  36. package/src/mcp/mark-applied.ts +103 -0
  37. package/src/mcp/route.ts +88 -0
  38. package/src/mcp/server.ts +59 -0
  39. package/src/mcp/style-guide-resource.ts +40 -0
  40. package/src/notify.ts +142 -0
  41. package/src/shim/daemon.ts +318 -0
  42. package/src/shim/lockfile.ts +153 -0
  43. package/src/shim/main.ts +172 -0
  44. package/src/shim/semver.ts +29 -0
  45. package/src/version.ts +4 -0
  46. package/tsconfig.json +26 -0
@@ -0,0 +1,425 @@
1
+ import type { SQLQueryBindings } from "bun:sqlite";
2
+ import { ulid } from "ulid";
3
+ import type { Store } from "./store.ts";
4
+
5
+ /** The four states of `docs/mcp-contract.md`'s status machine; the schema's CHECK keeps rows inside this domain. */
6
+ export type EntryStatus = "draft" | "approved" | "applied" | "rejected";
7
+
8
+ /**
9
+ * One entry as it crosses into the store: every field already typed, hashed, and stringified.
10
+ * The json boundary belongs to the tool that reads the wire (`src/mcp/file-entries.ts`); nothing
11
+ * here re-parses what it already accepted. `agentDraft`, `contextJson`, and `constraintsJson` are
12
+ * null when the caller did not send the field: a first filing stores the empty default, and a
13
+ * draft re-file keeps the stored value (the schema refuses explicit null, so no caller can
14
+ * express a wipe).
15
+ */
16
+ export type NewEntry = {
17
+ readonly repo: string;
18
+ readonly file: string;
19
+ readonly anchorText: string;
20
+ readonly anchorBefore: string;
21
+ readonly anchorAfter: string;
22
+ readonly anchorHash: string;
23
+ readonly fileHash: string;
24
+ readonly agentDraft: string | null;
25
+ readonly contextJson: string | null;
26
+ readonly constraintsJson: string | null;
27
+ };
28
+
29
+ export type FiledEntry = {
30
+ readonly id: string;
31
+ readonly status: EntryStatus;
32
+ readonly deduped: boolean;
33
+ readonly updated: boolean;
34
+ };
35
+
36
+ export type FiledBatch = {
37
+ readonly batchId: string;
38
+ readonly results: readonly FiledEntry[];
39
+ };
40
+
41
+ type ProjectRow = { id: string };
42
+ type IdentityRow = { id: string; status: EntryStatus };
43
+
44
+ /** One entry row as `list_entries` returns it: the columns `docs/mcp-contract.md`'s entry schema lists, `archived_at` included (every v1 row carries null — the archive sweep is not built yet). */
45
+ export type EntryRow = {
46
+ readonly id: string;
47
+ readonly project_id: string;
48
+ readonly batch_id: string;
49
+ readonly repo: string;
50
+ readonly file: string;
51
+ readonly anchor_text: string;
52
+ readonly anchor_before: string;
53
+ readonly anchor_after: string;
54
+ readonly anchor_hash: string;
55
+ readonly file_hash: string;
56
+ readonly agent_draft: string | null;
57
+ readonly human_text: string | null;
58
+ readonly status: EntryStatus;
59
+ readonly context: string;
60
+ readonly constraints: string;
61
+ readonly filed_by: string | null;
62
+ readonly stale_note: string | null;
63
+ readonly applied_hash: string | null;
64
+ readonly created_at: number;
65
+ readonly updated_at: number;
66
+ readonly applied_at: number | null;
67
+ readonly archived_at: number | null;
68
+ };
69
+
70
+ /**
71
+ * Every filter is `| undefined`, never optional: the tool always passes all of them, so the query
72
+ * builder can test presence with `!== undefined` and the caller never wonders which keys exist.
73
+ */
74
+ export type ListEntriesFilter = {
75
+ readonly projectId: string | undefined;
76
+ readonly status: EntryStatus | undefined;
77
+ readonly ids: readonly string[] | undefined;
78
+ readonly q: string | undefined;
79
+ readonly limit: number;
80
+ readonly cursor: string | undefined;
81
+ };
82
+
83
+ export type ListEntriesResult = {
84
+ readonly rows: readonly EntryRow[];
85
+ /**
86
+ * The next page's cursor, present only when the page is full: `rows.length === limit` means more
87
+ * rows may exist beyond the last id. An absent cursor means the walk is exhausted, and an empty
88
+ * page never carries one.
89
+ */
90
+ readonly nextCursor: string | null;
91
+ };
92
+
93
+ /**
94
+ * One approved row as `fetch_approved` returns it: the applying needs the contract's parenthetical
95
+ * "text + anchor + constraints" and nothing else — no `context`, no `status`, since everything here
96
+ * is approved by construction. `constraints` stays the stored string; the tool owns the parse.
97
+ */
98
+ export type ApprovedEntryRow = {
99
+ readonly id: string;
100
+ readonly repo: string;
101
+ readonly file: string;
102
+ readonly anchor_text: string;
103
+ readonly anchor_before: string;
104
+ readonly anchor_after: string;
105
+ readonly anchor_hash: string;
106
+ readonly file_hash: string;
107
+ readonly human_text: string | null;
108
+ readonly constraints: string;
109
+ };
110
+
111
+ export type ApprovedEntriesFilter = {
112
+ readonly ids: readonly string[] | undefined;
113
+ readonly since: string | undefined;
114
+ };
115
+
116
+ export type ApprovedEntriesResult = {
117
+ readonly rows: readonly ApprovedEntryRow[];
118
+ /**
119
+ * The last returned id, present only when the page is full: `rows.length === 50` means more rows
120
+ * may exist beyond it. An absent `nextSince` means the walk is exhausted, and an empty page never
121
+ * carries one.
122
+ */
123
+ readonly nextSince: string | null;
124
+ };
125
+
126
+ /**
127
+ * Resolves a slug to its project id. Read tools call this and refuse on `null`: the contract's
128
+ * error table makes an unknown project a business refusal on reads, and only `ensureProject`
129
+ * (the write path) may mint a project.
130
+ */
131
+ export function projectIdBySlug(store: Store, slug: string): string | null {
132
+ const row = store.db.query("SELECT id FROM projects WHERE slug = ?").get(slug) as ProjectRow | null;
133
+ return row?.id ?? null;
134
+ }
135
+
136
+ type ProjectSlugRow = { id: string; slug: string };
137
+
138
+ /**
139
+ * Every known project, ordered by slug. The dashboard reads this for the project filter dropdown
140
+ * and to resolve the `project_id` on entry rows back to the slug its groups are labelled with.
141
+ */
142
+ export function listProjects(store: Store): { id: string; slug: string }[] {
143
+ return store.db.query("SELECT id, slug FROM projects ORDER BY slug").all() as ProjectSlugRow[];
144
+ }
145
+
146
+ /** The number of entries in the store, whatever their status; the dashboard's "showing X of Y" count. */
147
+ export function countEntries(store: Store): number {
148
+ const row = store.db.query("SELECT COUNT(*) AS n FROM entries").get() as { n: number };
149
+ return row.n;
150
+ }
151
+
152
+ /**
153
+ * `INSERT ... ON CONFLICT DO NOTHING` followed by the read: two calls racing to auto-create the
154
+ * same slug both succeed, and the second takes the first's id rather than dying on `UNIQUE(slug)`.
155
+ * Both statements run inside this connection's transaction, and sqlite serializes writers, so no
156
+ * competing write can land between the insert and its select. Concurrent readers under WAL see
157
+ * pre-commit state and are unaffected either way.
158
+ */
159
+ function ensureProject(store: Store, slug: string): string {
160
+ store.db.run("INSERT INTO projects (id, slug, created_at) VALUES (?, ?, ?) ON CONFLICT (slug) DO NOTHING", [
161
+ ulid(),
162
+ slug,
163
+ Date.now(),
164
+ ]);
165
+ const row = projectIdBySlug(store, slug);
166
+ if (row === null) throw new Error(`reviewzy: project "${slug}" missing right after its own insert`);
167
+ return row;
168
+ }
169
+
170
+ /**
171
+ * One entry against the frozen identity key `(project_id, repo, file, anchor_hash)`, honoring the
172
+ * re-file table exactly: no row inserts a second time; a `draft` row is overwritten in place by
173
+ * the fields the newer call carries (and nothing else — the first filing's provenance stays);
174
+ * anything past `draft` is untouched, which is what silences a re-file against a rejected anchor.
175
+ */
176
+ function fileOneEntry(
177
+ db: Store["db"],
178
+ projectId: string,
179
+ batchId: string,
180
+ filedBy: string | null,
181
+ entry: NewEntry,
182
+ ): FiledEntry {
183
+ const existing = db
184
+ .query("SELECT id, status FROM entries WHERE project_id = ? AND repo = ? AND file = ? AND anchor_hash = ?")
185
+ .get(projectId, entry.repo, entry.file, entry.anchorHash) as IdentityRow | null;
186
+
187
+ if (existing === null) {
188
+ const id = ulid();
189
+ const now = Date.now();
190
+ db.run(
191
+ `INSERT INTO entries (
192
+ id, project_id, batch_id, repo, file, anchor_text, anchor_before, anchor_after,
193
+ anchor_hash, file_hash, agent_draft, human_text, status, context, constraints,
194
+ filed_by, stale_note, created_at, updated_at, applied_at, archived_at
195
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'draft', ?, ?, ?, NULL, ?, ?, NULL, NULL)`,
196
+ [
197
+ id,
198
+ projectId,
199
+ batchId,
200
+ entry.repo,
201
+ entry.file,
202
+ entry.anchorText,
203
+ entry.anchorBefore,
204
+ entry.anchorAfter,
205
+ entry.anchorHash,
206
+ entry.fileHash,
207
+ entry.agentDraft,
208
+ entry.contextJson ?? "{}",
209
+ entry.constraintsJson ?? "{}",
210
+ filedBy,
211
+ now,
212
+ now,
213
+ ],
214
+ );
215
+ return { id, status: "draft", deduped: false, updated: false };
216
+ }
217
+
218
+ if (existing.status === "draft") {
219
+ // `COALESCE(?, column)`: a field the newer call did not carry (null) keeps the stored value.
220
+ // The schema refuses explicit null, so a re-file can only move the fields it names.
221
+ db.run(
222
+ "UPDATE entries SET agent_draft = COALESCE(?, agent_draft), context = COALESCE(?, context), constraints = COALESCE(?, constraints), updated_at = ? WHERE id = ?",
223
+ [entry.agentDraft, entry.contextJson, entry.constraintsJson, Date.now(), existing.id],
224
+ );
225
+ return { id: existing.id, status: "draft", deduped: true, updated: true };
226
+ }
227
+
228
+ // approved, applied, rejected: the existing entry answers, and nothing is written. The cast is
229
+ // paid for by the schema's CHECK constraint: no other value can sit in this column.
230
+ return { id: existing.id, status: existing.status, deduped: true, updated: false };
231
+ }
232
+
233
+ /**
234
+ * Files one `file_entries` call: a single minted batch id shared by every entry, the project
235
+ * auto-created on its first appearance, and every entry resolved against the identity key — all in
236
+ * one transaction, so a caller sees either the whole batch or none of it.
237
+ */
238
+ export function fileEntries(
239
+ store: Store,
240
+ projectSlug: string,
241
+ filedBy: string | null,
242
+ entries: readonly NewEntry[],
243
+ ): FiledBatch {
244
+ const batchId = ulid();
245
+ const write = store.db.transaction(() => {
246
+ const projectId = ensureProject(store, projectSlug);
247
+ return entries.map((entry) => fileOneEntry(store.db, projectId, batchId, filedBy, entry));
248
+ });
249
+ return { batchId, results: write() };
250
+ }
251
+
252
+ /** The `fetch_approved` page size `docs/mcp-contract.md` pins: 50, fixed server-side. */
253
+ const APPROVED_PAGE_SIZE = 50;
254
+
255
+ /** The columns `docs/mcp-contract.md`'s entry schema names; the one place the output row's shape is spelled out in SQL. */
256
+ const ENTRY_COLUMNS = [
257
+ "id", "project_id", "batch_id", "repo", "file", "anchor_text", "anchor_before",
258
+ "anchor_after", "anchor_hash", "file_hash", "agent_draft", "human_text", "status",
259
+ "context", "constraints", "filed_by", "stale_note", "applied_hash", "created_at", "updated_at", "applied_at", "archived_at",
260
+ ] as const;
261
+
262
+ /**
263
+ * The `list_entries` read. Every filter is optional and combines with AND; rows come back in
264
+ * ascending id order (ulids sort chronologically), and `cursor` continues the walk from after that
265
+ * id — keyset pagination, so a page is exactly the rows between the cursor and the limit, with no
266
+ * way to duplicate or skip one.
267
+ */
268
+ export function listEntries(store: Store, filter: ListEntriesFilter): ListEntriesResult {
269
+ const where: string[] = [];
270
+ const params: SQLQueryBindings[] = [];
271
+
272
+ if (filter.projectId !== undefined) {
273
+ where.push("project_id = ?");
274
+ params.push(filter.projectId);
275
+ }
276
+ if (filter.status !== undefined) {
277
+ where.push("status = ?");
278
+ params.push(filter.status);
279
+ }
280
+ if (filter.ids !== undefined) {
281
+ // The zod boundary refuses an empty list, so this never emits `IN ()` — which sqlite would
282
+ // accept as a no-match, not an error: a direct call with `ids: []` returns no rows.
283
+ where.push(`id IN (${filter.ids.map(() => "?").join(", ")})`);
284
+ params.push(...filter.ids);
285
+ }
286
+ if (filter.q !== undefined) {
287
+ // `instr(lower(...), lower(?)) > 0` is a literal substring: `%` and `_` in the query match
288
+ // themselves, unlike a LIKE pattern. `lower` folds ASCII case only — the same fold LIKE does.
289
+ where.push(
290
+ `(instr(lower(file), lower(?)) > 0 OR instr(lower(anchor_text), lower(?)) > 0 OR instr(lower(agent_draft), lower(?)) > 0 OR instr(lower(human_text), lower(?)) > 0)`,
291
+ );
292
+ params.push(filter.q, filter.q, filter.q, filter.q);
293
+ }
294
+ if (filter.cursor !== undefined) {
295
+ where.push("id > ?");
296
+ params.push(filter.cursor);
297
+ }
298
+
299
+ const sql = `SELECT ${ENTRY_COLUMNS.join(", ")} FROM entries${where.length > 0 ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY id LIMIT ?`;
300
+ params.push(filter.limit);
301
+
302
+ const rows = store.db.query(sql).all(...params) as EntryRow[];
303
+ const nextCursor = rows.length === filter.limit ? rows[rows.length - 1]!.id : null;
304
+ return { rows, nextCursor };
305
+ }
306
+
307
+ /**
308
+ * The `fetch_approved` read. The status is not a filter: the tool's invariant is `'approved'`
309
+ * (a draft, applied, or rejected row never leaves this query), and the page size is the contract's
310
+ * fixed 50 — the client has no limit param, so this number is the whole page contract. `ids` and
311
+ * `since` combine with AND, and `since` is the same keyset the list tool uses, so a resumed walk
312
+ * cannot duplicate or skip a row.
313
+ */
314
+ export function approvedEntries(
315
+ store: Store,
316
+ projectId: string,
317
+ filter: ApprovedEntriesFilter,
318
+ ): ApprovedEntriesResult {
319
+ const where = ["project_id = ?", "status = 'approved'"];
320
+ const params: SQLQueryBindings[] = [projectId];
321
+
322
+ if (filter.ids !== undefined) {
323
+ // The zod boundary refuses an empty list, so this never emits `IN ()` — which sqlite would
324
+ // accept as a no-match, not an error: a direct call with `ids: []` returns no rows.
325
+ where.push(`id IN (${filter.ids.map(() => "?").join(", ")})`);
326
+ params.push(...filter.ids);
327
+ }
328
+ if (filter.since !== undefined) {
329
+ where.push("id > ?");
330
+ params.push(filter.since);
331
+ }
332
+
333
+ const sql = `SELECT id, repo, file, anchor_text, anchor_before, anchor_after, anchor_hash, file_hash, human_text, constraints FROM entries WHERE ${where.join(" AND ")} ORDER BY id LIMIT ?`;
334
+ params.push(APPROVED_PAGE_SIZE);
335
+
336
+ const rows = store.db.query(sql).all(...params) as ApprovedEntryRow[];
337
+ const nextSince = rows.length === APPROVED_PAGE_SIZE ? rows[rows.length - 1]!.id : null;
338
+ return { rows, nextSince };
339
+ }
340
+
341
+ /**
342
+ * The `await_approved` read: every named id's row in the fetch_approved column shape plus status,
343
+ * so one query answers both the status map and the approved slice a resolve carries. The caller
344
+ * (the tool) dedupes ids and zod refuses an empty list, so this never emits `IN ()` — which sqlite
345
+ * would accept as a no-match, not an error: a direct call with `ids: []` returns no rows.
346
+ */
347
+ export function entriesByIds(
348
+ store: Store,
349
+ ids: readonly string[],
350
+ ): readonly (ApprovedEntryRow & { readonly status: EntryStatus })[] {
351
+ const sql = `SELECT id, status, repo, file, anchor_text, anchor_before, anchor_after, anchor_hash, file_hash, human_text, constraints FROM entries WHERE id IN (${ids.map(() => "?").join(", ")})`;
352
+ return store.db.query(sql).all(...ids) as (ApprovedEntryRow & { readonly status: EntryStatus })[];
353
+ }
354
+
355
+ /** The two outcomes `mark_applied` may report: the text was applied, or the anchor no longer matches. */
356
+ export type ApplyResult = "applied" | "anchor_stale";
357
+
358
+ export type MarkAppliedInput = {
359
+ readonly id: string;
360
+ readonly result: ApplyResult;
361
+ /** The sha256 hex of the text that was applied; null when the call omitted it. */
362
+ readonly appliedHash: string | null;
363
+ /** What the anchor matched instead; null when the call omitted it. */
364
+ readonly foundText: string | null;
365
+ };
366
+
367
+ /** Everything the store refuses, in the contract's terms; the tool formats the message naming the id. */
368
+ export type MarkAppliedRefusal =
369
+ | { readonly kind: "unknown" }
370
+ | { readonly kind: "status"; readonly status: EntryStatus };
371
+
372
+ export type MarkAppliedOutcome =
373
+ | { readonly ok: true; readonly status: "applied" | "approved" }
374
+ | { readonly ok: false; readonly refusal: MarkAppliedRefusal };
375
+
376
+ /**
377
+ * The `mark_applied` write: the whole agent-side transition surface, owned here so no tool layer
378
+ * ever decides a status change. The contract's matrix is exhaustive — `approved`→`applied`, and
379
+ * `approved`|`applied`→`approved` on a stale anchor — and every other (id, status) pair is a
380
+ * refusal carrying the status the tool names. The dashboard's transitions (`draft`→`approved`,
381
+ * →`rejected`) are not expressible here, which is what keeps an agent from ever reaching either
382
+ * state. A refusal returns before any write, so the row is untouched, `updated_at` included.
383
+ */
384
+ export function markApplied(store: Store, input: MarkAppliedInput): MarkAppliedOutcome {
385
+ const row = store.db.query("SELECT status FROM entries WHERE id = ?").get(input.id) as
386
+ | { status: EntryStatus }
387
+ | null;
388
+ if (row === null) return { ok: false, refusal: { kind: "unknown" } };
389
+
390
+ const now = Date.now();
391
+ if (input.result === "applied") {
392
+ if (row.status !== "approved") {
393
+ return { ok: false, refusal: { kind: "status", status: row.status } };
394
+ }
395
+ // `COALESCE(?, applied_hash)` is the pinned semantics whole: a call with the hash overwrites
396
+ // any earlier value, a call without one leaves the stored value alone.
397
+ store.db.run(
398
+ "UPDATE entries SET status = 'applied', applied_at = ?, updated_at = ?, applied_hash = COALESCE(?, applied_hash) WHERE id = ?",
399
+ [now, now, input.appliedHash, input.id],
400
+ );
401
+ // The store-scoped event bus, after the write so a waiter's re-read sees the new status:
402
+ // await_approved waiters on this entry re-check immediately instead of waiting out a poll.
403
+ store.notifyStatusChange(input.id, "applied");
404
+ return { ok: true, status: "applied" };
405
+ }
406
+
407
+ // anchor_stale: the entry stays or returns to `approved` with the note. The note is written even
408
+ // when `found_text` is absent — the pinned rule is stale_note = found_text, null included, so a
409
+ // stale report without the found text clears a previous note instead of keeping a stale one.
410
+ // `applied_at` and `applied_hash` are only ever written by an apply; a stale report leaves the
411
+ // last apply's stamp alone.
412
+ if (row.status !== "approved" && row.status !== "applied") {
413
+ return { ok: false, refusal: { kind: "status", status: row.status } };
414
+ }
415
+ store.db.run("UPDATE entries SET status = 'approved', stale_note = ?, updated_at = ? WHERE id = ?", [
416
+ input.foundText,
417
+ now,
418
+ input.id,
419
+ ]);
420
+ // The status does not always move (approved + anchor_stale stays approved), but the write is a
421
+ // successful transition either way: the event means "statuses may have changed", and a waiter's
422
+ // re-check is the decision, so a false positive costs one read.
423
+ store.notifyStatusChange(input.id, "approved");
424
+ return { ok: true, status: "approved" };
425
+ }
@@ -0,0 +1,83 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { mkdirSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import type { Config } from "../config.ts";
5
+ import type { EntryStatus } from "./queries.ts";
6
+ import { runMigrations } from "./migrate.ts";
7
+
8
+ export type Store = {
9
+ readonly db: Database;
10
+ /**
11
+ * The store-scoped status-change bus, one per `openStore`: `await_approved` waits on it, and the
12
+ * write paths dispatch on it. Never module-global — tests run several apps in one process, and a
13
+ * shared bus would cross-talk between stores.
14
+ */
15
+ readonly events: EventTarget;
16
+ /** Dispatches a status change on the bus; every status-writing path calls it after a successful transition. */
17
+ notifyStatusChange(id: string, status: EntryStatus): void;
18
+ /** Registers an in-flight `await_approved` waiter for the drain path; returns the unregister call. */
19
+ registerWaiter(resolve: () => void): () => void;
20
+ /** Resolves every registered waiter; the `/drain` route calls it before shutdown, so long-poll responses flush while the server still serves. */
21
+ resolveInFlightWaiters(): void;
22
+ /** The number of registered waiters; the teardown pins read it to prove a settled wait unregistered itself. */
23
+ waiterCount(): number;
24
+ close(): void;
25
+ };
26
+
27
+ /** The one bus event: an entry's status changed. The detail carries {id, status}; a listener that only cares that something changed may ignore it. */
28
+ export const STATUS_CHANGE_EVENT = "entry-status-changed";
29
+
30
+ /** The operator-supplied `REVIEWZY_DB` path rejected a required setting, distinct from a bug in the daemon. */
31
+ export class StoreError extends Error {
32
+ override readonly name = "StoreError";
33
+ }
34
+
35
+ /**
36
+ * Opens (creating if absent) the sqlite file at `config.REVIEWZY_DB`, enables WAL journaling and
37
+ * foreign-key enforcement, and applies every pending migration before returning. Both pragmas
38
+ * reset on every new connection, so they are set here rather than assumed from a prior boot.
39
+ */
40
+ export function openStore(config: Config): Store {
41
+ mkdirSync(dirname(config.REVIEWZY_DB), { recursive: true });
42
+
43
+ const db = new Database(config.REVIEWZY_DB, { create: true });
44
+
45
+ // `PRAGMA journal_mode = WAL` silently falls back (e.g. `memory` for `:memory:`, or `delete` on
46
+ // a filesystem that can't do WAL) instead of erroring, so read the mode it actually landed on.
47
+ const { journal_mode: journalMode } = db.query("PRAGMA journal_mode = WAL").get() as {
48
+ journal_mode: string;
49
+ };
50
+ if (journalMode.toLowerCase() !== "wal") {
51
+ throw new StoreError(
52
+ `reviewzy: sqlite at ${config.REVIEWZY_DB} could not enable WAL journaling (landed on '${journalMode}' instead); WAL needs a local filesystem, not a network mount. Point REVIEWZY_DB at local disk.`,
53
+ );
54
+ }
55
+
56
+ db.run("PRAGMA foreign_keys = ON");
57
+
58
+ runMigrations(db);
59
+
60
+ const events = new EventTarget();
61
+ const waiters = new Set<() => void>();
62
+
63
+ return {
64
+ db,
65
+ events,
66
+ notifyStatusChange: (id, status) => {
67
+ events.dispatchEvent(new CustomEvent(STATUS_CHANGE_EVENT, { detail: { id, status } }));
68
+ },
69
+ registerWaiter: (resolve) => {
70
+ waiters.add(resolve);
71
+ return () => {
72
+ waiters.delete(resolve);
73
+ };
74
+ },
75
+ // A snapshot copy: a waiter unregisters itself while resolving, so iterating the live set
76
+ // while calling the handlers would skip a later waiter.
77
+ resolveInFlightWaiters: () => {
78
+ for (const resolve of [...waiters]) resolve();
79
+ },
80
+ waiterCount: () => waiters.size,
81
+ close: () => db.close(),
82
+ };
83
+ }