···11-CREATE TABLE `did_handles` (
11+CREATE TABLE IF NOT EXISTS `did_handles` (
22 `did` text PRIMARY KEY NOT NULL,
33 `handle` text NOT NULL,
44 `updated_at` text DEFAULT (datetime('now')) NOT NULL
+10
drizzle/0011_military_diamondback.sql
···11+DROP INDEX IF EXISTS `idx_feature_requests_sphere`;--> statement-breakpoint
22+DROP INDEX IF EXISTS `idx_feature_requests_status`;--> statement-breakpoint
33+-- Safety: an earlier iteration of this migration created a 2-column
44+-- `(sphere_id, hidden_at)` index here. The 3-column composite below supersedes
55+-- it; drop it if present so no orphan index remains on DBs that applied it.
66+DROP INDEX IF EXISTS `idx_feature_requests_sphere_hidden`;--> statement-breakpoint
77+CREATE INDEX IF NOT EXISTS `idx_feature_requests_sphere_hidden_status` ON `feature_requests` (`sphere_id`,`hidden_at`,`status`);--> statement-breakpoint
88+DROP INDEX IF EXISTS `idx_kanban_tasks_sphere`;--> statement-breakpoint
99+DROP INDEX IF EXISTS `idx_kanban_tasks_status`;--> statement-breakpoint
1010+CREATE INDEX IF NOT EXISTS `idx_kanban_tasks_sphere_hidden` ON `kanban_tasks` (`sphere_id`,`hidden_at`);
···2828 "pds:logs": "docker compose -f docker-compose.dev.yml logs -f pds",
2929 "pds:account": "bun run scripts/pds-account.ts",
3030 "generate:lexicons": "bun scripts/generate-lexicon-types.ts",
3131- "db:generate": "drizzle-kit generate",
3131+ "db:generate": "drizzle-kit generate && bun run scripts/fix-journal-ordering.ts",
3232 "db:migrate": "bun run packages/core/src/db/migrate.ts",
3333 "build": "vp build --outDir dist/client && vp build --ssr src/entry-server.tsx --outDir dist/server --emptyOutDir false",
3434 "start": "NODE_ENV=production bun run packages/app/src/server.ts",
+15-5
packages/app/src/client.tsx
···4141 ssrPageData.value = ssrData.pageData;
4242 }
4343} else {
4444- // Fallback: no SSR data (dev without SSR), behave as before
4444+ // Fallback: no SSR data (dev without SSR), behave as before. Note: this
4545+ // branch has no `pageshow` listener, so bfcache restores keep stale data.
4646+ // Acceptable because this path isn't hit in prod.
4547 checkSession();
4648 const handle = getSphereHandleFromUrl();
4749 loadSphere(handle ?? undefined);
···49515052hydrate(<App />, document.getElementById("app")!);
51535252-// After hydration, silently refresh data in background to catch changes
5353-// Use refreshSphere (not loadSphere) to avoid resetting state to pending/null
5454+// SSR already seeded auth + sphere from the server's own cache, so the page
5555+// ships hydrated with fresh data. No immediate refresh — that was just a
5656+// duplicate round-trip. Instead, refresh on `pageshow` when the page comes
5757+// out of the bfcache (back/forward nav) where SSR data can be arbitrarily
5858+// stale. Regular navigations call loadSphere/refreshSphere explicitly where
5959+// the UX expects it (settings edits, invitation banner).
5460if (ssrData) {
5555- checkSession();
5656- refreshSphere();
6161+ window.addEventListener("pageshow", (event) => {
6262+ if (event.persisted) {
6363+ checkSession();
6464+ refreshSphere();
6565+ }
6666+ });
5767}
+33
packages/core/src/db/migrate.ts
···11import { Database } from "bun:sqlite";
22+import { readFileSync } from "node:fs";
33+import { join } from "node:path";
24import { migrate } from "drizzle-orm/bun-sqlite/migrator";
35import { getDb } from "./index.ts";
4657const migrationsFolder = process.env.MIGRATIONS_PATH || "drizzle";
6899+assertJournalMonotonic(migrationsFolder);
710migrate(getDb(), { migrationsFolder });
1111+1212+/**
1313+ * The sqlite migrator skips any migration whose `when` is ≤
1414+ * `max(__drizzle_migrations.created_at)` on the target DB. If the journal has
1515+ * non-monotonic `when` values, a newer entry can silently get skipped — the
1616+ * kind of bug that only surfaces as "that table is missing in prod". Fail
1717+ * loudly at startup instead, pointing to the fix script.
1818+ */
1919+function assertJournalMonotonic(folder: string): void {
2020+ const journalPath = join(folder, "meta/_journal.json");
2121+ let journal: { entries: { idx: number; when: number; tag: string }[] };
2222+ try {
2323+ journal = JSON.parse(readFileSync(journalPath, "utf-8"));
2424+ } catch {
2525+ // No journal (fresh project) — nothing to validate.
2626+ return;
2727+ }
2828+ const entries = [...journal.entries].sort((a, b) => a.idx - b.idx);
2929+ let maxWhen = Number.NEGATIVE_INFINITY;
3030+ for (const entry of entries) {
3131+ if (entry.when <= maxWhen) {
3232+ throw new Error(
3333+ `[migrate] journal is not monotonic: ${entry.tag} has when=${entry.when} but a prior ` +
3434+ `entry has when=${maxWhen}. The migrator would silently skip this migration. ` +
3535+ `Run \`bun run scripts/fix-journal-ordering.ts\` to repair the journal, then commit.`,
3636+ );
3737+ }
3838+ maxWhen = entry.when;
3939+ }
4040+}
841942// Data migration: renumber feature requests and kanban tasks to use the
1043// shared sphere_entry_counter. Runs only once — skips if counter rows exist.
+10-2
packages/feature-requests/src/db/schema.ts
···3636 },
3737 (table) => [
3838 uniqueIndex("idx_feature_requests_sphere_number").on(table.sphereId, table.number),
3939- index("idx_feature_requests_sphere").on(table.sphereId),
4040- index("idx_feature_requests_status").on(table.status),
3939+ // Covers the dashboard stats query and the status-filtered list query —
4040+ // both filter on sphere + visibility (+ optional status), matching this
4141+ // composite's column order exactly, so SQLite can satisfy them from the
4242+ // index alone. Sphere-only filters use the leftmost prefix. Replaces the
4343+ // prior single-column (sphere_id) and (status) indexes.
4444+ index("idx_feature_requests_sphere_hidden_status").on(
4545+ table.sphereId,
4646+ table.hiddenAt,
4747+ table.status,
4848+ ),
4149 ],
4250);
4351
+230
packages/kanban/src/__tests__/stats.test.ts
···11+import { describe, it, expect, vi, beforeEach } from "vitest";
22+import { Hono } from "hono";
33+import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
44+55+import { createTestDb, seedSphere } from "../../../core/src/__tests__/helpers/test-db.ts";
66+77+let db: BetterSQLite3Database;
88+99+vi.mock("@exosphere/core/db", () => ({
1010+ getDb: () => db,
1111+}));
1212+1313+import { kanbanTasks, kanbanColumns } from "../db/schema.ts";
1414+import { statsApi } from "../api/stats.ts";
1515+1616+const SPHERE_ID = "test-sphere-001";
1717+const OWNER_DID = "did:plc:owner";
1818+const AUTHOR_DID = "did:plc:author";
1919+2020+interface TaskSeed {
2121+ id: string;
2222+ number: number;
2323+ status: string;
2424+ title?: string;
2525+}
2626+2727+function seedColumn(
2828+ slug: string,
2929+ statusType: string,
3030+ opts: { label?: string; position?: number } = {},
3131+) {
3232+ db.insert(kanbanColumns)
3333+ .values({
3434+ id: `col-${slug}`,
3535+ sphereId: SPHERE_ID,
3636+ slug,
3737+ label: opts.label ?? slug,
3838+ statusType,
3939+ position: opts.position ?? 0,
4040+ })
4141+ .run();
4242+}
4343+4444+function seedTask(t: TaskSeed) {
4545+ db.insert(kanbanTasks)
4646+ .values({
4747+ id: t.id,
4848+ sphereId: SPHERE_ID,
4949+ number: t.number,
5050+ authorDid: AUTHOR_DID,
5151+ title: t.title ?? `Task ${t.number}`,
5252+ status: t.status,
5353+ position: t.number * 1000,
5454+ })
5555+ .run();
5656+}
5757+5858+/** Mini app: inject sphereId via middleware, mount the real statsApi. */
5959+function buildApp() {
6060+ const app = new Hono();
6161+ app.use("*", async (c, next) => {
6262+ c.set("sphereId", SPHERE_ID);
6363+ await next();
6464+ });
6565+ app.route("/", statsApi);
6666+ return app;
6767+}
6868+6969+beforeEach(() => {
7070+ db = createTestDb();
7171+ seedSphere(db, { id: SPHERE_ID, handle: "test.bsky.social", ownerDid: OWNER_DID });
7272+});
7373+7474+describe("GET /stats", () => {
7575+ it("counts tasks by statusType via the column join", async () => {
7676+ seedColumn("backlog", "backlog");
7777+ seedColumn("doing", "started", { label: "Doing" });
7878+ seedColumn("done", "completed", { label: "Done" });
7979+8080+ seedTask({ id: "3mhy7w6tbg22a", number: 1, status: "backlog" });
8181+ seedTask({ id: "3mhy7w6tbg22b", number: 2, status: "backlog" });
8282+ seedTask({ id: "3mhy7w6tbg22c", number: 3, status: "doing" });
8383+ seedTask({ id: "3mhy7w6tbg22d", number: 4, status: "done" });
8484+8585+ const res = await buildApp().request("/stats");
8686+ expect(res.status).toBe(200);
8787+ const body = (await res.json()) as {
8888+ total: number;
8989+ statusTypeCounts: Record<string, number>;
9090+ };
9191+9292+ expect(body.total).toBe(4);
9393+ expect(body.statusTypeCounts.backlog).toBe(2);
9494+ expect(body.statusTypeCounts.started).toBe(1);
9595+ expect(body.statusTypeCounts.completed).toBe(1);
9696+ expect(body.statusTypeCounts.planned).toBe(0);
9797+ expect(body.statusTypeCounts.canceled).toBe(0);
9898+ });
9999+100100+ it("still counts orphan tasks toward total when their status slug has no matching column", async () => {
101101+ // Only one column mapped; the "removed-status" slug has no column,
102102+ // simulating a column that was deleted while tasks still reference it.
103103+ seedColumn("backlog", "backlog");
104104+105105+ seedTask({ id: "3mhy7w6tbg22a", number: 1, status: "backlog" });
106106+ seedTask({ id: "3mhy7w6tbg22b", number: 2, status: "removed-status" });
107107+ seedTask({ id: "3mhy7w6tbg22c", number: 3, status: "also-gone" });
108108+109109+ const res = await buildApp().request("/stats");
110110+ expect(res.status).toBe(200);
111111+ const body = (await res.json()) as {
112112+ total: number;
113113+ statusTypeCounts: Record<string, number>;
114114+ };
115115+116116+ // All three tasks are counted — the orphans must not silently drop.
117117+ expect(body.total).toBe(3);
118118+ // Only the mapped task contributes to a named statusType bucket.
119119+ expect(body.statusTypeCounts.backlog).toBe(1);
120120+ expect(body.statusTypeCounts.started).toBe(0);
121121+ expect(body.statusTypeCounts.completed).toBe(0);
122122+ });
123123+124124+ it("isolates counts per sphere — tasks from other spheres never leak in", async () => {
125125+ const OTHER_SPHERE = "test-sphere-002";
126126+ seedSphere(db, { id: OTHER_SPHERE, handle: "other.bsky.social", ownerDid: OWNER_DID });
127127+128128+ // Current sphere: 1 task.
129129+ seedColumn("backlog", "backlog");
130130+ seedTask({ id: "3mhy7w6tbg22a", number: 1, status: "backlog" });
131131+132132+ // Other sphere: 3 tasks, its own column. These must not show up.
133133+ db.insert(kanbanColumns)
134134+ .values({
135135+ id: "col-other-backlog",
136136+ sphereId: OTHER_SPHERE,
137137+ slug: "backlog",
138138+ label: "backlog",
139139+ statusType: "backlog",
140140+ position: 0,
141141+ })
142142+ .run();
143143+ for (let i = 0; i < 3; i++) {
144144+ db.insert(kanbanTasks)
145145+ .values({
146146+ id: `3mhy7w6tbg23${String.fromCharCode(97 + i)}`,
147147+ sphereId: OTHER_SPHERE,
148148+ number: i + 1,
149149+ authorDid: AUTHOR_DID,
150150+ title: `Other ${i}`,
151151+ status: "backlog",
152152+ position: (i + 1) * 1000,
153153+ })
154154+ .run();
155155+ }
156156+157157+ const res = await buildApp().request("/stats");
158158+ const body = (await res.json()) as {
159159+ total: number;
160160+ statusTypeCounts: Record<string, number>;
161161+ latestTasks: unknown[];
162162+ };
163163+164164+ expect(body.total).toBe(1);
165165+ expect(body.statusTypeCounts.backlog).toBe(1);
166166+ expect(body.latestTasks).toHaveLength(1);
167167+ });
168168+169169+ it("returns latestTasks enriched with statusType + label, excluding orphans", async () => {
170170+ seedColumn("backlog", "backlog", { label: "Backlog" });
171171+ seedColumn("doing", "started", { label: "Doing" });
172172+173173+ // Newest-first by TID: 22d > 22c > 22b > 22a
174174+ seedTask({ id: "3mhy7w6tbg22a", number: 1, status: "backlog", title: "oldest" });
175175+ seedTask({ id: "3mhy7w6tbg22b", number: 2, status: "doing", title: "second" });
176176+ // Orphan at position 3 — should be filtered from latestTasks but still counted.
177177+ seedTask({ id: "3mhy7w6tbg22c", number: 3, status: "vanished", title: "orphan" });
178178+ seedTask({ id: "3mhy7w6tbg22d", number: 4, status: "backlog", title: "newest" });
179179+180180+ const res = await buildApp().request("/stats");
181181+ const body = (await res.json()) as {
182182+ total: number;
183183+ statusTypeCounts: Record<string, number>;
184184+ latestTasks: {
185185+ id: string;
186186+ title: string;
187187+ statusType: string;
188188+ statusLabel: string;
189189+ }[];
190190+ };
191191+192192+ expect(body.total).toBe(4);
193193+ // Orphan absent from the list even though it's newest-but-one.
194194+ const titles = body.latestTasks.map((t) => t.title);
195195+ expect(titles).not.toContain("orphan");
196196+ expect(titles[0]).toBe("newest");
197197+ // Shape check: statusType and label come from the joined column row.
198198+ const newest = body.latestTasks[0];
199199+ expect(newest.statusType).toBe("backlog");
200200+ expect(newest.statusLabel).toBe("Backlog");
201201+ });
202202+203203+ it("excludes hidden tasks from total and per-status counts", async () => {
204204+ seedColumn("backlog", "backlog");
205205+206206+ seedTask({ id: "3mhy7w6tbg22a", number: 1, status: "backlog" });
207207+ // Hidden task — should not contribute.
208208+ db.insert(kanbanTasks)
209209+ .values({
210210+ id: "3mhy7w6tbg22b",
211211+ sphereId: SPHERE_ID,
212212+ number: 2,
213213+ authorDid: AUTHOR_DID,
214214+ title: "Hidden",
215215+ status: "backlog",
216216+ position: 2000,
217217+ hiddenAt: "2026-01-01T00:00:00Z",
218218+ })
219219+ .run();
220220+221221+ const res = await buildApp().request("/stats");
222222+ const body = (await res.json()) as {
223223+ total: number;
224224+ statusTypeCounts: Record<string, number>;
225225+ };
226226+227227+ expect(body.total).toBe(1);
228228+ expect(body.statusTypeCounts.backlog).toBe(1);
229229+ });
230230+});
+35-27
packages/kanban/src/api/stats.ts
···11import { Hono } from "hono";
22import { getDb } from "@exosphere/core/db";
33-import { eq, and, sql, count, desc } from "@exosphere/core/db/drizzle";
33+import { eq, and, sql, desc } from "@exosphere/core/db/drizzle";
44import { collectCachedHandles, warmDidHandles } from "@exosphere/core/identity";
55import { tidToDate } from "@exosphere/core/pds";
66import type { AuthEnv } from "@exosphere/core/auth";
···1818const app = new Hono<AuthEnv & SphereEnv>();
19192020// Dashboard stats: task counts grouped by StatusType + latest tasks.
2121-// Counts are computed by joining tasks with their column so we group by
2222-// the column's statusType, not by the column slug. One JOIN + GROUP BY.
2121+// Both queries LEFT JOIN `kanban_columns` on (sphereId, slug) — the count
2222+// query groups by the resolved statusType (so we can surface per-type totals),
2323+// and the latest query pulls statusType/label for rendering. LEFT JOIN so
2424+// orphan tasks (whose status slug no longer maps to a column) don't silently
2525+// drop from `total`; the .map() below filters them out of `latestTasks`.
2326app.get("/stats", async (c) => {
2427 const db = getDb();
2528 const sphereId = c.var.sphereId;
26293030+ // LEFT JOIN so orphan tasks (status slug no longer maps to any column)
3131+ // still appear: they collapse into a single null-statusType group whose
3232+ // count feeds `total` but not `statusTypeCounts`. One query instead of two.
2733 const countRows = db
2834 .select({
2935 statusType: kanbanColumns.statusType,
3036 count: sql<number>`count(${kanbanTasks.id})`.as("count"),
3137 })
3238 .from(kanbanTasks)
3333- .innerJoin(
3939+ .leftJoin(
3440 kanbanColumns,
3541 and(
3642 eq(kanbanColumns.sphereId, kanbanTasks.sphereId),
···4147 .groupBy(kanbanColumns.statusType)
4248 .all();
43494444- // Count all tasks (including any orphans whose status slug no longer maps to a column)
4545- // so the total matches reality even if the join-based counts drop some rows.
4646- const totalRow = db
4747- .select({ total: count() })
4848- .from(kanbanTasks)
4949- .where(and(eq(kanbanTasks.sphereId, sphereId), sql`${kanbanTasks.hiddenAt} is null`))
5050- .get();
5151- const total = totalRow?.total ?? 0;
5252-5350 const statusTypeCounts: Record<StatusType, number> = {
5451 backlog: 0,
5552 planned: 0,
···5754 completed: 0,
5855 canceled: 0,
5956 };
5757+ let total = 0;
6058 for (const row of countRows) {
6161- if (isStatusType(row.statusType)) {
5959+ total += row.count;
6060+ if (row.statusType && isStatusType(row.statusType)) {
6261 statusTypeCounts[row.statusType] = row.count;
6362 }
6463 }
···7574 updatedAt: kanbanTasks.updatedAt,
7675 })
7776 .from(kanbanTasks)
7878- .innerJoin(
7777+ .leftJoin(
7978 kanbanColumns,
8079 and(
8180 eq(kanbanColumns.sphereId, kanbanTasks.sphereId),
···9392 const { handles, toWarm } = collectCachedHandles(latestRows, (r) => r.authorDid);
9493 if (toWarm.length) warmDidHandles(toWarm);
95949696- const latestTasks = latestRows.map((r) => ({
9797- id: r.id,
9898- number: r.number,
9999- title: r.title,
100100- status: r.status,
101101- statusType: r.statusType as StatusType,
102102- statusLabel: r.statusLabel,
103103- authorDid: r.authorDid,
104104- authorHandle: handles.get(r.authorDid) ?? null,
105105- createdAt: tidToDate(r.id),
106106- updatedAt: r.updatedAt,
107107- }));
9595+ // Drop orphans (no matching column → null statusType/label); the UI can't
9696+ // render them without a column. They still contribute to `total` above.
9797+ // Rare case: if a recent task is orphaned, this list shows fewer than the
9898+ // limit — acceptable trade-off vs. over-fetching on the common path.
9999+ const latestTasks = latestRows
100100+ .filter(
101101+ (r): r is typeof r & { statusType: string; statusLabel: string } =>
102102+ r.statusType !== null && r.statusLabel !== null,
103103+ )
104104+ .map((r) => ({
105105+ id: r.id,
106106+ number: r.number,
107107+ title: r.title,
108108+ status: r.status,
109109+ statusType: r.statusType as StatusType,
110110+ statusLabel: r.statusLabel,
111111+ authorDid: r.authorDid,
112112+ authorHandle: handles.get(r.authorDid) ?? null,
113113+ createdAt: tidToDate(r.id),
114114+ updatedAt: r.updatedAt,
115115+ }));
108116109117 return c.json({
110118 total,
+5-2
packages/kanban/src/db/schema.ts
···4848 },
4949 (table) => [
5050 uniqueIndex("idx_kanban_tasks_sphere_number").on(table.sphereId, table.number),
5151- index("idx_kanban_tasks_sphere").on(table.sphereId),
5252- index("idx_kanban_tasks_status").on(table.status),
5151+ // Serves board queries (sphere + status, ordered by position) and — via
5252+ // leftmost prefix — sphere-only filters. Makes the old single-column
5353+ // (sphere_id) and (status) indexes redundant; both have been dropped.
5354 index("idx_kanban_tasks_sphere_status_position").on(
5455 table.sphereId,
5556 table.status,
5657 table.position,
5758 ),
5959+ // Dashboard stats filter by sphere + visibility on every request.
6060+ index("idx_kanban_tasks_sphere_hidden").on(table.sphereId, table.hiddenAt),
5861 ],
5962);
6063
+75
scripts/fix-journal-ordering.ts
···11+/**
22+ * Ensure `drizzle/meta/_journal.json` has strictly-monotonic `when` values.
33+ *
44+ * Why: drizzle's sqlite migrator skips any migration whose `when` is ≤
55+ * `max(__drizzle_migrations.created_at)` on the target DB. If a newly-generated
66+ * migration's `when` (wall-clock `Date.now()` at generate-time) is smaller than
77+ * an earlier entry's — which happens here because a few older entries were
88+ * manually bumped into the future — the migrator silently skips the new one.
99+ *
1010+ * This script walks the journal in `idx` order. Any entry whose `when` is not
1111+ * strictly greater than the running max is rewritten to `max + 1`, so every
1212+ * subsequent migration is guaranteed to be picked up.
1313+ *
1414+ * Runs idempotently — no-op if the journal is already monotonic.
1515+ *
1616+ * Usage: `bun run scripts/fix-journal-ordering.ts` (wired into `db:generate`).
1717+ */
1818+1919+import { readFileSync, writeFileSync } from "node:fs";
2020+import { join } from "node:path";
2121+2222+const ROOT = join(import.meta.dir, "..");
2323+const JOURNAL_PATH = join(ROOT, "drizzle/meta/_journal.json");
2424+2525+interface JournalEntry {
2626+ idx: number;
2727+ version: string;
2828+ when: number;
2929+ tag: string;
3030+ breakpoints: boolean;
3131+}
3232+3333+interface Journal {
3434+ version: string;
3535+ dialect: string;
3636+ entries: JournalEntry[];
3737+}
3838+3939+const raw = readFileSync(JOURNAL_PATH, "utf-8");
4040+const journal: Journal = JSON.parse(raw);
4141+4242+// Sort by idx to be defensive — the file is already idx-ordered but nothing
4343+// enforces that.
4444+const entries = [...journal.entries].sort((a, b) => a.idx - b.idx);
4545+4646+const bumps: { tag: string; from: number; to: number }[] = [];
4747+let maxWhen = Number.NEGATIVE_INFINITY;
4848+for (const entry of entries) {
4949+ if (entry.when > maxWhen) {
5050+ maxWhen = entry.when;
5151+ continue;
5252+ }
5353+ // Non-monotonic: bump to max + 1 so it's strictly greater than every prior.
5454+ const next = maxWhen + 1;
5555+ bumps.push({ tag: entry.tag, from: entry.when, to: next });
5656+ entry.when = next;
5757+ maxWhen = next;
5858+}
5959+6060+if (bumps.length === 0) {
6161+ console.log("[fix-journal-ordering] journal already monotonic — nothing to do");
6262+ process.exit(0);
6363+}
6464+6565+journal.entries = entries;
6666+// Preserve trailing newline — drizzle-kit writes one, and removing it churns diffs.
6767+const serialized = `${JSON.stringify(journal, null, 2)}\n`;
6868+writeFileSync(JOURNAL_PATH, serialized);
6969+7070+console.log(
7171+ `[fix-journal-ordering] bumped ${bumps.length} entr${bumps.length === 1 ? "y" : "ies"}:`,
7272+);
7373+for (const b of bumps) {
7474+ console.log(` ${b.tag}: ${b.from} → ${b.to}`);
7575+}