diff --git a/drizzle/0010_dark_red_skull.sql b/drizzle/0010_dark_red_skull.sql
index a0de836..6edb0d1 100644
--- a/drizzle/0010_dark_red_skull.sql
+++ b/drizzle/0010_dark_red_skull.sql
@@ -1,4 +1,4 @@
-CREATE TABLE `did_handles` (
+CREATE TABLE IF NOT EXISTS `did_handles` (
`did` text PRIMARY KEY NOT NULL,
`handle` text NOT NULL,
`updated_at` text DEFAULT (datetime('now')) NOT NULL
diff --git a/drizzle/0011_military_diamondback.sql b/drizzle/0011_military_diamondback.sql
new file mode 100644
index 0000000..6064f2f
--- /dev/null
+++ b/drizzle/0011_military_diamondback.sql
@@ -0,0 +1,10 @@
+DROP INDEX IF EXISTS `idx_feature_requests_sphere`;--> statement-breakpoint
+DROP INDEX IF EXISTS `idx_feature_requests_status`;--> statement-breakpoint
+-- Safety: an earlier iteration of this migration created a 2-column
+-- `(sphere_id, hidden_at)` index here. The 3-column composite below supersedes
+-- it; drop it if present so no orphan index remains on DBs that applied it.
+DROP INDEX IF EXISTS `idx_feature_requests_sphere_hidden`;--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS `idx_feature_requests_sphere_hidden_status` ON `feature_requests` (`sphere_id`,`hidden_at`,`status`);--> statement-breakpoint
+DROP INDEX IF EXISTS `idx_kanban_tasks_sphere`;--> statement-breakpoint
+DROP INDEX IF EXISTS `idx_kanban_tasks_status`;--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS `idx_kanban_tasks_sphere_hidden` ON `kanban_tasks` (`sphere_id`,`hidden_at`);
\ No newline at end of file
diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json
new file mode 100644
index 0000000..52643e3
--- /dev/null
+++ b/drizzle/meta/0011_snapshot.json
@@ -0,0 +1,1599 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "e31ac405-322f-439b-aec2-d8f775b4b2f8",
+ "prevId": "8a2b0a3b-d03a-466d-b73f-141810627773",
+ "tables": {
+ "oauth_sessions": {
+ "name": "oauth_sessions",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session": {
+ "name": "session",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "oauth_states": {
+ "name": "oauth_states",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "indexer_cursor": {
+ "name": "indexer_cursor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'jetstream'"
+ },
+ "cursor": {
+ "name": "cursor",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sphere_entry_counter": {
+ "name": "sphere_entry_counter",
+ "columns": {
+ "sphere_id": {
+ "name": "sphere_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_number": {
+ "name": "last_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sphere_entry_counter_sphere_id_spheres_id_fk": {
+ "name": "sphere_entry_counter_sphere_id_spheres_id_fk",
+ "tableFrom": "sphere_entry_counter",
+ "tableTo": "spheres",
+ "columnsFrom": [
+ "sphere_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "did_handles": {
+ "name": "did_handles",
+ "columns": {
+ "did": {
+ "name": "did",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "handle": {
+ "name": "handle",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "entity_labels": {
+ "name": "entity_labels",
+ "columns": {
+ "label_id": {
+ "name": "label_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_entity_labels_entity": {
+ "name": "idx_entity_labels_entity",
+ "columns": [
+ "entity_id",
+ "entity_type"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "entity_labels_label_id_sphere_labels_id_fk": {
+ "name": "entity_labels_label_id_sphere_labels_id_fk",
+ "tableFrom": "entity_labels",
+ "tableTo": "sphere_labels",
+ "columnsFrom": [
+ "label_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "entity_labels_label_id_entity_id_pk": {
+ "columns": [
+ "label_id",
+ "entity_id"
+ ],
+ "name": "entity_labels_label_id_entity_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "label_pds_records": {
+ "name": "label_pds_records",
+ "columns": {
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "actor_did": {
+ "name": "actor_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rkey": {
+ "name": "rkey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "label_pds_records_entity_id_entity_type_actor_did_pk": {
+ "columns": [
+ "entity_id",
+ "entity_type",
+ "actor_did"
+ ],
+ "name": "label_pds_records_entity_id_entity_type_actor_did_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sphere_labels": {
+ "name": "sphere_labels",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sphere_id": {
+ "name": "sphere_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ }
+ },
+ "indexes": {
+ "idx_sphere_labels_sphere_name": {
+ "name": "idx_sphere_labels_sphere_name",
+ "columns": [
+ "sphere_id",
+ "name"
+ ],
+ "isUnique": true
+ },
+ "idx_sphere_labels_sphere_position": {
+ "name": "idx_sphere_labels_sphere_position",
+ "columns": [
+ "sphere_id",
+ "position"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "sphere_labels_sphere_id_spheres_id_fk": {
+ "name": "sphere_labels_sphere_id_spheres_id_fk",
+ "tableFrom": "sphere_labels",
+ "tableTo": "spheres",
+ "columnsFrom": [
+ "sphere_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sphere_members": {
+ "name": "sphere_members",
+ "columns": {
+ "sphere_id": {
+ "name": "sphere_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "did": {
+ "name": "did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'invited'"
+ },
+ "invited_by": {
+ "name": "invited_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pds_uri": {
+ "name": "pds_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "approval_pds_uri": {
+ "name": "approval_pds_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ }
+ },
+ "indexes": {
+ "idx_sphere_members_did": {
+ "name": "idx_sphere_members_did",
+ "columns": [
+ "did"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "sphere_members_sphere_id_spheres_id_fk": {
+ "name": "sphere_members_sphere_id_spheres_id_fk",
+ "tableFrom": "sphere_members",
+ "tableTo": "spheres",
+ "columnsFrom": [
+ "sphere_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "sphere_members_sphere_id_did_pk": {
+ "columns": [
+ "sphere_id",
+ "did"
+ ],
+ "name": "sphere_members_sphere_id_did_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sphere_modules": {
+ "name": "sphere_modules",
+ "columns": {
+ "sphere_id": {
+ "name": "sphere_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "module_name": {
+ "name": "module_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled_at": {
+ "name": "enabled_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sphere_modules_sphere_id_spheres_id_fk": {
+ "name": "sphere_modules_sphere_id_spheres_id_fk",
+ "tableFrom": "sphere_modules",
+ "tableTo": "spheres",
+ "columnsFrom": [
+ "sphere_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "sphere_modules_sphere_id_module_name_pk": {
+ "columns": [
+ "sphere_id",
+ "module_name"
+ ],
+ "name": "sphere_modules_sphere_id_module_name_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sphere_permissions": {
+ "name": "sphere_permissions",
+ "columns": {
+ "sphere_id": {
+ "name": "sphere_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action_key": {
+ "name": "action_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "min_role": {
+ "name": "min_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sphere_permissions_sphere_id_spheres_id_fk": {
+ "name": "sphere_permissions_sphere_id_spheres_id_fk",
+ "tableFrom": "sphere_permissions",
+ "tableTo": "spheres",
+ "columnsFrom": [
+ "sphere_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "sphere_permissions_sphere_id_action_key_pk": {
+ "columns": [
+ "sphere_id",
+ "action_key"
+ ],
+ "name": "sphere_permissions_sphere_id_action_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "spheres": {
+ "name": "spheres",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "handle": {
+ "name": "handle",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'public'"
+ },
+ "owner_did": {
+ "name": "owner_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "pds_uri": {
+ "name": "pds_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ }
+ },
+ "indexes": {
+ "spheres_handle_unique": {
+ "name": "spheres_handle_unique",
+ "columns": [
+ "handle"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "feature_request_comment_votes": {
+ "name": "feature_request_comment_votes",
+ "columns": {
+ "comment_id": {
+ "name": "comment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_did": {
+ "name": "author_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "pds_uri": {
+ "name": "pds_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ }
+ },
+ "indexes": {
+ "idx_feature_request_comment_votes_comment": {
+ "name": "idx_feature_request_comment_votes_comment",
+ "columns": [
+ "comment_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "feature_request_comment_votes_comment_id_feature_request_comments_id_fk": {
+ "name": "feature_request_comment_votes_comment_id_feature_request_comments_id_fk",
+ "tableFrom": "feature_request_comment_votes",
+ "tableTo": "feature_request_comments",
+ "columnsFrom": [
+ "comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "feature_request_comment_votes_comment_id_author_did_pk": {
+ "columns": [
+ "comment_id",
+ "author_did"
+ ],
+ "name": "feature_request_comment_votes_comment_id_author_did_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "feature_request_comments": {
+ "name": "feature_request_comments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_did": {
+ "name": "author_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "pds_uri": {
+ "name": "pds_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ },
+ "hidden_at": {
+ "name": "hidden_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "moderated_by": {
+ "name": "moderated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_feature_request_comments_request": {
+ "name": "idx_feature_request_comments_request",
+ "columns": [
+ "request_id"
+ ],
+ "isUnique": false
+ },
+ "idx_feature_request_comments_author_request": {
+ "name": "idx_feature_request_comments_author_request",
+ "columns": [
+ "author_did",
+ "request_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "feature_request_comments_request_id_feature_requests_id_fk": {
+ "name": "feature_request_comments_request_id_feature_requests_id_fk",
+ "tableFrom": "feature_request_comments",
+ "tableTo": "feature_requests",
+ "columnsFrom": [
+ "request_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "feature_request_statuses": {
+ "name": "feature_request_statuses",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_did": {
+ "name": "author_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "pds_uri": {
+ "name": "pds_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_feature_request_statuses_request": {
+ "name": "idx_feature_request_statuses_request",
+ "columns": [
+ "request_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "feature_request_statuses_request_id_feature_requests_id_fk": {
+ "name": "feature_request_statuses_request_id_feature_requests_id_fk",
+ "tableFrom": "feature_request_statuses",
+ "tableTo": "feature_requests",
+ "columnsFrom": [
+ "request_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "feature_request_votes": {
+ "name": "feature_request_votes",
+ "columns": {
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_did": {
+ "name": "author_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "pds_uri": {
+ "name": "pds_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ }
+ },
+ "indexes": {
+ "idx_feature_request_votes_request": {
+ "name": "idx_feature_request_votes_request",
+ "columns": [
+ "request_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "feature_request_votes_request_id_feature_requests_id_fk": {
+ "name": "feature_request_votes_request_id_feature_requests_id_fk",
+ "tableFrom": "feature_request_votes",
+ "tableTo": "feature_requests",
+ "columnsFrom": [
+ "request_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "feature_request_votes_request_id_author_did_pk": {
+ "columns": [
+ "request_id",
+ "author_did"
+ ],
+ "name": "feature_request_votes_request_id_author_did_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "feature_requests": {
+ "name": "feature_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sphere_id": {
+ "name": "sphere_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "number": {
+ "name": "number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_did": {
+ "name": "author_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'requested'"
+ },
+ "duplicate_of_id": {
+ "name": "duplicate_of_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pds_uri": {
+ "name": "pds_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "hidden_at": {
+ "name": "hidden_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "moderated_by": {
+ "name": "moderated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "label_updated_at": {
+ "name": "label_updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ }
+ },
+ "indexes": {
+ "idx_feature_requests_sphere_number": {
+ "name": "idx_feature_requests_sphere_number",
+ "columns": [
+ "sphere_id",
+ "number"
+ ],
+ "isUnique": true
+ },
+ "idx_feature_requests_sphere_hidden_status": {
+ "name": "idx_feature_requests_sphere_hidden_status",
+ "columns": [
+ "sphere_id",
+ "hidden_at",
+ "status"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "feature_requests_sphere_id_spheres_id_fk": {
+ "name": "feature_requests_sphere_id_spheres_id_fk",
+ "tableFrom": "feature_requests",
+ "tableTo": "spheres",
+ "columnsFrom": [
+ "sphere_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "feed_posts": {
+ "name": "feed_posts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_did": {
+ "name": "author_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pds_uri": {
+ "name": "pds_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ }
+ },
+ "indexes": {
+ "idx_feed_posts_parent": {
+ "name": "idx_feed_posts_parent",
+ "columns": [
+ "parent_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "kanban_columns": {
+ "name": "kanban_columns",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sphere_id": {
+ "name": "sphere_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status_type": {
+ "name": "status_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'backlog'"
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ }
+ },
+ "indexes": {
+ "idx_kanban_columns_sphere_slug": {
+ "name": "idx_kanban_columns_sphere_slug",
+ "columns": [
+ "sphere_id",
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "idx_kanban_columns_sphere_position": {
+ "name": "idx_kanban_columns_sphere_position",
+ "columns": [
+ "sphere_id",
+ "position"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "kanban_columns_sphere_id_spheres_id_fk": {
+ "name": "kanban_columns_sphere_id_spheres_id_fk",
+ "tableFrom": "kanban_columns",
+ "tableTo": "spheres",
+ "columnsFrom": [
+ "sphere_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "kanban_task_comments": {
+ "name": "kanban_task_comments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "task_id": {
+ "name": "task_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_did": {
+ "name": "author_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "pds_uri": {
+ "name": "pds_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ },
+ "hidden_at": {
+ "name": "hidden_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "moderated_by": {
+ "name": "moderated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_kanban_task_comments_task": {
+ "name": "idx_kanban_task_comments_task",
+ "columns": [
+ "task_id"
+ ],
+ "isUnique": false
+ },
+ "idx_kanban_task_comments_author_task": {
+ "name": "idx_kanban_task_comments_author_task",
+ "columns": [
+ "author_did",
+ "task_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "kanban_task_comments_task_id_kanban_tasks_id_fk": {
+ "name": "kanban_task_comments_task_id_kanban_tasks_id_fk",
+ "tableFrom": "kanban_task_comments",
+ "tableTo": "kanban_tasks",
+ "columnsFrom": [
+ "task_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "kanban_task_status_changes": {
+ "name": "kanban_task_status_changes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "task_id": {
+ "name": "task_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_did": {
+ "name": "author_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "pds_uri": {
+ "name": "pds_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_kanban_task_status_changes_task": {
+ "name": "idx_kanban_task_status_changes_task",
+ "columns": [
+ "task_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "kanban_task_status_changes_task_id_kanban_tasks_id_fk": {
+ "name": "kanban_task_status_changes_task_id_kanban_tasks_id_fk",
+ "tableFrom": "kanban_task_status_changes",
+ "tableTo": "kanban_tasks",
+ "columnsFrom": [
+ "task_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "kanban_tasks": {
+ "name": "kanban_tasks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sphere_id": {
+ "name": "sphere_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "number": {
+ "name": "number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_did": {
+ "name": "author_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'backlog'"
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "assignee_did": {
+ "name": "assignee_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pds_uri": {
+ "name": "pds_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "hidden_at": {
+ "name": "hidden_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "moderated_by": {
+ "name": "moderated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "label_updated_at": {
+ "name": "label_updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(datetime('now'))"
+ }
+ },
+ "indexes": {
+ "idx_kanban_tasks_sphere_number": {
+ "name": "idx_kanban_tasks_sphere_number",
+ "columns": [
+ "sphere_id",
+ "number"
+ ],
+ "isUnique": true
+ },
+ "idx_kanban_tasks_sphere_status_position": {
+ "name": "idx_kanban_tasks_sphere_status_position",
+ "columns": [
+ "sphere_id",
+ "status",
+ "position"
+ ],
+ "isUnique": false
+ },
+ "idx_kanban_tasks_sphere_hidden": {
+ "name": "idx_kanban_tasks_sphere_hidden",
+ "columns": [
+ "sphere_id",
+ "hidden_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "kanban_tasks_sphere_id_spheres_id_fk": {
+ "name": "kanban_tasks_sphere_id_spheres_id_fk",
+ "tableFrom": "kanban_tasks",
+ "tableTo": "spheres",
+ "columnsFrom": [
+ "sphere_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index e4db27f..1e79050 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -68,16 +68,23 @@
{
"idx": 9,
"version": "6",
- "when": 1776516974088,
+ "when": 1776800000001,
"tag": "0009_damp_microbe",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
- "when": 1776547678213,
+ "when": 1776800000002,
"tag": "0010_dark_red_skull",
"breakpoints": true
+ },
+ {
+ "idx": 11,
+ "version": "6",
+ "when": 1776800000003,
+ "tag": "0011_military_diamondback",
+ "breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/package.json b/package.json
index 0181557..3103e11 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,7 @@
"pds:logs": "docker compose -f docker-compose.dev.yml logs -f pds",
"pds:account": "bun run scripts/pds-account.ts",
"generate:lexicons": "bun scripts/generate-lexicon-types.ts",
- "db:generate": "drizzle-kit generate",
+ "db:generate": "drizzle-kit generate && bun run scripts/fix-journal-ordering.ts",
"db:migrate": "bun run packages/core/src/db/migrate.ts",
"build": "vp build --outDir dist/client && vp build --ssr src/entry-server.tsx --outDir dist/server --emptyOutDir false",
"start": "NODE_ENV=production bun run packages/app/src/server.ts",
diff --git a/packages/app/src/client.tsx b/packages/app/src/client.tsx
index a8834e5..7191cd8 100644
--- a/packages/app/src/client.tsx
+++ b/packages/app/src/client.tsx
@@ -41,7 +41,9 @@ if (ssrData) {
ssrPageData.value = ssrData.pageData;
}
} else {
- // Fallback: no SSR data (dev without SSR), behave as before
+ // Fallback: no SSR data (dev without SSR), behave as before. Note: this
+ // branch has no `pageshow` listener, so bfcache restores keep stale data.
+ // Acceptable because this path isn't hit in prod.
checkSession();
const handle = getSphereHandleFromUrl();
loadSphere(handle ?? undefined);
@@ -49,9 +51,17 @@ if (ssrData) {
hydrate(, document.getElementById("app")!);
-// After hydration, silently refresh data in background to catch changes
-// Use refreshSphere (not loadSphere) to avoid resetting state to pending/null
+// SSR already seeded auth + sphere from the server's own cache, so the page
+// ships hydrated with fresh data. No immediate refresh — that was just a
+// duplicate round-trip. Instead, refresh on `pageshow` when the page comes
+// out of the bfcache (back/forward nav) where SSR data can be arbitrarily
+// stale. Regular navigations call loadSphere/refreshSphere explicitly where
+// the UX expects it (settings edits, invitation banner).
if (ssrData) {
- checkSession();
- refreshSphere();
+ window.addEventListener("pageshow", (event) => {
+ if (event.persisted) {
+ checkSession();
+ refreshSphere();
+ }
+ });
}
diff --git a/packages/core/src/db/migrate.ts b/packages/core/src/db/migrate.ts
index 65b43ff..7562de5 100644
--- a/packages/core/src/db/migrate.ts
+++ b/packages/core/src/db/migrate.ts
@@ -1,11 +1,44 @@
import { Database } from "bun:sqlite";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { getDb } from "./index.ts";
const migrationsFolder = process.env.MIGRATIONS_PATH || "drizzle";
+assertJournalMonotonic(migrationsFolder);
migrate(getDb(), { migrationsFolder });
+/**
+ * The sqlite migrator skips any migration whose `when` is ≤
+ * `max(__drizzle_migrations.created_at)` on the target DB. If the journal has
+ * non-monotonic `when` values, a newer entry can silently get skipped — the
+ * kind of bug that only surfaces as "that table is missing in prod". Fail
+ * loudly at startup instead, pointing to the fix script.
+ */
+function assertJournalMonotonic(folder: string): void {
+ const journalPath = join(folder, "meta/_journal.json");
+ let journal: { entries: { idx: number; when: number; tag: string }[] };
+ try {
+ journal = JSON.parse(readFileSync(journalPath, "utf-8"));
+ } catch {
+ // No journal (fresh project) — nothing to validate.
+ return;
+ }
+ const entries = [...journal.entries].sort((a, b) => a.idx - b.idx);
+ let maxWhen = Number.NEGATIVE_INFINITY;
+ for (const entry of entries) {
+ if (entry.when <= maxWhen) {
+ throw new Error(
+ `[migrate] journal is not monotonic: ${entry.tag} has when=${entry.when} but a prior ` +
+ `entry has when=${maxWhen}. The migrator would silently skip this migration. ` +
+ `Run \`bun run scripts/fix-journal-ordering.ts\` to repair the journal, then commit.`,
+ );
+ }
+ maxWhen = entry.when;
+ }
+}
+
// Data migration: renumber feature requests and kanban tasks to use the
// shared sphere_entry_counter. Runs only once — skips if counter rows exist.
seedEntryCounters();
diff --git a/packages/feature-requests/src/db/schema.ts b/packages/feature-requests/src/db/schema.ts
index eba02fc..d8dd5f5 100644
--- a/packages/feature-requests/src/db/schema.ts
+++ b/packages/feature-requests/src/db/schema.ts
@@ -36,8 +36,16 @@ export const featureRequests = sqliteTable(
},
(table) => [
uniqueIndex("idx_feature_requests_sphere_number").on(table.sphereId, table.number),
- index("idx_feature_requests_sphere").on(table.sphereId),
- index("idx_feature_requests_status").on(table.status),
+ // Covers the dashboard stats query and the status-filtered list query —
+ // both filter on sphere + visibility (+ optional status), matching this
+ // composite's column order exactly, so SQLite can satisfy them from the
+ // index alone. Sphere-only filters use the leftmost prefix. Replaces the
+ // prior single-column (sphere_id) and (status) indexes.
+ index("idx_feature_requests_sphere_hidden_status").on(
+ table.sphereId,
+ table.hiddenAt,
+ table.status,
+ ),
],
);
diff --git a/packages/kanban/src/__tests__/stats.test.ts b/packages/kanban/src/__tests__/stats.test.ts
new file mode 100644
index 0000000..0eacf04
--- /dev/null
+++ b/packages/kanban/src/__tests__/stats.test.ts
@@ -0,0 +1,230 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { Hono } from "hono";
+import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
+
+import { createTestDb, seedSphere } from "../../../core/src/__tests__/helpers/test-db.ts";
+
+let db: BetterSQLite3Database;
+
+vi.mock("@exosphere/core/db", () => ({
+ getDb: () => db,
+}));
+
+import { kanbanTasks, kanbanColumns } from "../db/schema.ts";
+import { statsApi } from "../api/stats.ts";
+
+const SPHERE_ID = "test-sphere-001";
+const OWNER_DID = "did:plc:owner";
+const AUTHOR_DID = "did:plc:author";
+
+interface TaskSeed {
+ id: string;
+ number: number;
+ status: string;
+ title?: string;
+}
+
+function seedColumn(
+ slug: string,
+ statusType: string,
+ opts: { label?: string; position?: number } = {},
+) {
+ db.insert(kanbanColumns)
+ .values({
+ id: `col-${slug}`,
+ sphereId: SPHERE_ID,
+ slug,
+ label: opts.label ?? slug,
+ statusType,
+ position: opts.position ?? 0,
+ })
+ .run();
+}
+
+function seedTask(t: TaskSeed) {
+ db.insert(kanbanTasks)
+ .values({
+ id: t.id,
+ sphereId: SPHERE_ID,
+ number: t.number,
+ authorDid: AUTHOR_DID,
+ title: t.title ?? `Task ${t.number}`,
+ status: t.status,
+ position: t.number * 1000,
+ })
+ .run();
+}
+
+/** Mini app: inject sphereId via middleware, mount the real statsApi. */
+function buildApp() {
+ const app = new Hono();
+ app.use("*", async (c, next) => {
+ c.set("sphereId", SPHERE_ID);
+ await next();
+ });
+ app.route("/", statsApi);
+ return app;
+}
+
+beforeEach(() => {
+ db = createTestDb();
+ seedSphere(db, { id: SPHERE_ID, handle: "test.bsky.social", ownerDid: OWNER_DID });
+});
+
+describe("GET /stats", () => {
+ it("counts tasks by statusType via the column join", async () => {
+ seedColumn("backlog", "backlog");
+ seedColumn("doing", "started", { label: "Doing" });
+ seedColumn("done", "completed", { label: "Done" });
+
+ seedTask({ id: "3mhy7w6tbg22a", number: 1, status: "backlog" });
+ seedTask({ id: "3mhy7w6tbg22b", number: 2, status: "backlog" });
+ seedTask({ id: "3mhy7w6tbg22c", number: 3, status: "doing" });
+ seedTask({ id: "3mhy7w6tbg22d", number: 4, status: "done" });
+
+ const res = await buildApp().request("/stats");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ total: number;
+ statusTypeCounts: Record;
+ };
+
+ expect(body.total).toBe(4);
+ expect(body.statusTypeCounts.backlog).toBe(2);
+ expect(body.statusTypeCounts.started).toBe(1);
+ expect(body.statusTypeCounts.completed).toBe(1);
+ expect(body.statusTypeCounts.planned).toBe(0);
+ expect(body.statusTypeCounts.canceled).toBe(0);
+ });
+
+ it("still counts orphan tasks toward total when their status slug has no matching column", async () => {
+ // Only one column mapped; the "removed-status" slug has no column,
+ // simulating a column that was deleted while tasks still reference it.
+ seedColumn("backlog", "backlog");
+
+ seedTask({ id: "3mhy7w6tbg22a", number: 1, status: "backlog" });
+ seedTask({ id: "3mhy7w6tbg22b", number: 2, status: "removed-status" });
+ seedTask({ id: "3mhy7w6tbg22c", number: 3, status: "also-gone" });
+
+ const res = await buildApp().request("/stats");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ total: number;
+ statusTypeCounts: Record;
+ };
+
+ // All three tasks are counted — the orphans must not silently drop.
+ expect(body.total).toBe(3);
+ // Only the mapped task contributes to a named statusType bucket.
+ expect(body.statusTypeCounts.backlog).toBe(1);
+ expect(body.statusTypeCounts.started).toBe(0);
+ expect(body.statusTypeCounts.completed).toBe(0);
+ });
+
+ it("isolates counts per sphere — tasks from other spheres never leak in", async () => {
+ const OTHER_SPHERE = "test-sphere-002";
+ seedSphere(db, { id: OTHER_SPHERE, handle: "other.bsky.social", ownerDid: OWNER_DID });
+
+ // Current sphere: 1 task.
+ seedColumn("backlog", "backlog");
+ seedTask({ id: "3mhy7w6tbg22a", number: 1, status: "backlog" });
+
+ // Other sphere: 3 tasks, its own column. These must not show up.
+ db.insert(kanbanColumns)
+ .values({
+ id: "col-other-backlog",
+ sphereId: OTHER_SPHERE,
+ slug: "backlog",
+ label: "backlog",
+ statusType: "backlog",
+ position: 0,
+ })
+ .run();
+ for (let i = 0; i < 3; i++) {
+ db.insert(kanbanTasks)
+ .values({
+ id: `3mhy7w6tbg23${String.fromCharCode(97 + i)}`,
+ sphereId: OTHER_SPHERE,
+ number: i + 1,
+ authorDid: AUTHOR_DID,
+ title: `Other ${i}`,
+ status: "backlog",
+ position: (i + 1) * 1000,
+ })
+ .run();
+ }
+
+ const res = await buildApp().request("/stats");
+ const body = (await res.json()) as {
+ total: number;
+ statusTypeCounts: Record;
+ latestTasks: unknown[];
+ };
+
+ expect(body.total).toBe(1);
+ expect(body.statusTypeCounts.backlog).toBe(1);
+ expect(body.latestTasks).toHaveLength(1);
+ });
+
+ it("returns latestTasks enriched with statusType + label, excluding orphans", async () => {
+ seedColumn("backlog", "backlog", { label: "Backlog" });
+ seedColumn("doing", "started", { label: "Doing" });
+
+ // Newest-first by TID: 22d > 22c > 22b > 22a
+ seedTask({ id: "3mhy7w6tbg22a", number: 1, status: "backlog", title: "oldest" });
+ seedTask({ id: "3mhy7w6tbg22b", number: 2, status: "doing", title: "second" });
+ // Orphan at position 3 — should be filtered from latestTasks but still counted.
+ seedTask({ id: "3mhy7w6tbg22c", number: 3, status: "vanished", title: "orphan" });
+ seedTask({ id: "3mhy7w6tbg22d", number: 4, status: "backlog", title: "newest" });
+
+ const res = await buildApp().request("/stats");
+ const body = (await res.json()) as {
+ total: number;
+ statusTypeCounts: Record;
+ latestTasks: {
+ id: string;
+ title: string;
+ statusType: string;
+ statusLabel: string;
+ }[];
+ };
+
+ expect(body.total).toBe(4);
+ // Orphan absent from the list even though it's newest-but-one.
+ const titles = body.latestTasks.map((t) => t.title);
+ expect(titles).not.toContain("orphan");
+ expect(titles[0]).toBe("newest");
+ // Shape check: statusType and label come from the joined column row.
+ const newest = body.latestTasks[0];
+ expect(newest.statusType).toBe("backlog");
+ expect(newest.statusLabel).toBe("Backlog");
+ });
+
+ it("excludes hidden tasks from total and per-status counts", async () => {
+ seedColumn("backlog", "backlog");
+
+ seedTask({ id: "3mhy7w6tbg22a", number: 1, status: "backlog" });
+ // Hidden task — should not contribute.
+ db.insert(kanbanTasks)
+ .values({
+ id: "3mhy7w6tbg22b",
+ sphereId: SPHERE_ID,
+ number: 2,
+ authorDid: AUTHOR_DID,
+ title: "Hidden",
+ status: "backlog",
+ position: 2000,
+ hiddenAt: "2026-01-01T00:00:00Z",
+ })
+ .run();
+
+ const res = await buildApp().request("/stats");
+ const body = (await res.json()) as {
+ total: number;
+ statusTypeCounts: Record;
+ };
+
+ expect(body.total).toBe(1);
+ expect(body.statusTypeCounts.backlog).toBe(1);
+ });
+});
diff --git a/packages/kanban/src/api/stats.ts b/packages/kanban/src/api/stats.ts
index ca70840..5e7ebcd 100644
--- a/packages/kanban/src/api/stats.ts
+++ b/packages/kanban/src/api/stats.ts
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { getDb } from "@exosphere/core/db";
-import { eq, and, sql, count, desc } from "@exosphere/core/db/drizzle";
+import { eq, and, sql, desc } from "@exosphere/core/db/drizzle";
import { collectCachedHandles, warmDidHandles } from "@exosphere/core/identity";
import { tidToDate } from "@exosphere/core/pds";
import type { AuthEnv } from "@exosphere/core/auth";
@@ -18,19 +18,25 @@ function isStatusType(value: string): value is StatusType {
const app = new Hono();
// Dashboard stats: task counts grouped by StatusType + latest tasks.
-// Counts are computed by joining tasks with their column so we group by
-// the column's statusType, not by the column slug. One JOIN + GROUP BY.
+// Both queries LEFT JOIN `kanban_columns` on (sphereId, slug) — the count
+// query groups by the resolved statusType (so we can surface per-type totals),
+// and the latest query pulls statusType/label for rendering. LEFT JOIN so
+// orphan tasks (whose status slug no longer maps to a column) don't silently
+// drop from `total`; the .map() below filters them out of `latestTasks`.
app.get("/stats", async (c) => {
const db = getDb();
const sphereId = c.var.sphereId;
+ // LEFT JOIN so orphan tasks (status slug no longer maps to any column)
+ // still appear: they collapse into a single null-statusType group whose
+ // count feeds `total` but not `statusTypeCounts`. One query instead of two.
const countRows = db
.select({
statusType: kanbanColumns.statusType,
count: sql`count(${kanbanTasks.id})`.as("count"),
})
.from(kanbanTasks)
- .innerJoin(
+ .leftJoin(
kanbanColumns,
and(
eq(kanbanColumns.sphereId, kanbanTasks.sphereId),
@@ -41,15 +47,6 @@ app.get("/stats", async (c) => {
.groupBy(kanbanColumns.statusType)
.all();
- // Count all tasks (including any orphans whose status slug no longer maps to a column)
- // so the total matches reality even if the join-based counts drop some rows.
- const totalRow = db
- .select({ total: count() })
- .from(kanbanTasks)
- .where(and(eq(kanbanTasks.sphereId, sphereId), sql`${kanbanTasks.hiddenAt} is null`))
- .get();
- const total = totalRow?.total ?? 0;
-
const statusTypeCounts: Record = {
backlog: 0,
planned: 0,
@@ -57,8 +54,10 @@ app.get("/stats", async (c) => {
completed: 0,
canceled: 0,
};
+ let total = 0;
for (const row of countRows) {
- if (isStatusType(row.statusType)) {
+ total += row.count;
+ if (row.statusType && isStatusType(row.statusType)) {
statusTypeCounts[row.statusType] = row.count;
}
}
@@ -75,7 +74,7 @@ app.get("/stats", async (c) => {
updatedAt: kanbanTasks.updatedAt,
})
.from(kanbanTasks)
- .innerJoin(
+ .leftJoin(
kanbanColumns,
and(
eq(kanbanColumns.sphereId, kanbanTasks.sphereId),
@@ -93,18 +92,27 @@ app.get("/stats", async (c) => {
const { handles, toWarm } = collectCachedHandles(latestRows, (r) => r.authorDid);
if (toWarm.length) warmDidHandles(toWarm);
- const latestTasks = latestRows.map((r) => ({
- id: r.id,
- number: r.number,
- title: r.title,
- status: r.status,
- statusType: r.statusType as StatusType,
- statusLabel: r.statusLabel,
- authorDid: r.authorDid,
- authorHandle: handles.get(r.authorDid) ?? null,
- createdAt: tidToDate(r.id),
- updatedAt: r.updatedAt,
- }));
+ // Drop orphans (no matching column → null statusType/label); the UI can't
+ // render them without a column. They still contribute to `total` above.
+ // Rare case: if a recent task is orphaned, this list shows fewer than the
+ // limit — acceptable trade-off vs. over-fetching on the common path.
+ const latestTasks = latestRows
+ .filter(
+ (r): r is typeof r & { statusType: string; statusLabel: string } =>
+ r.statusType !== null && r.statusLabel !== null,
+ )
+ .map((r) => ({
+ id: r.id,
+ number: r.number,
+ title: r.title,
+ status: r.status,
+ statusType: r.statusType as StatusType,
+ statusLabel: r.statusLabel,
+ authorDid: r.authorDid,
+ authorHandle: handles.get(r.authorDid) ?? null,
+ createdAt: tidToDate(r.id),
+ updatedAt: r.updatedAt,
+ }));
return c.json({
total,
diff --git a/packages/kanban/src/db/schema.ts b/packages/kanban/src/db/schema.ts
index debc175..66b1fa6 100644
--- a/packages/kanban/src/db/schema.ts
+++ b/packages/kanban/src/db/schema.ts
@@ -48,13 +48,16 @@ export const kanbanTasks = sqliteTable(
},
(table) => [
uniqueIndex("idx_kanban_tasks_sphere_number").on(table.sphereId, table.number),
- index("idx_kanban_tasks_sphere").on(table.sphereId),
- index("idx_kanban_tasks_status").on(table.status),
+ // Serves board queries (sphere + status, ordered by position) and — via
+ // leftmost prefix — sphere-only filters. Makes the old single-column
+ // (sphere_id) and (status) indexes redundant; both have been dropped.
index("idx_kanban_tasks_sphere_status_position").on(
table.sphereId,
table.status,
table.position,
),
+ // Dashboard stats filter by sphere + visibility on every request.
+ index("idx_kanban_tasks_sphere_hidden").on(table.sphereId, table.hiddenAt),
],
);
diff --git a/scripts/fix-journal-ordering.ts b/scripts/fix-journal-ordering.ts
new file mode 100644
index 0000000..5bb1ed9
--- /dev/null
+++ b/scripts/fix-journal-ordering.ts
@@ -0,0 +1,75 @@
+/**
+ * Ensure `drizzle/meta/_journal.json` has strictly-monotonic `when` values.
+ *
+ * Why: drizzle's sqlite migrator skips any migration whose `when` is ≤
+ * `max(__drizzle_migrations.created_at)` on the target DB. If a newly-generated
+ * migration's `when` (wall-clock `Date.now()` at generate-time) is smaller than
+ * an earlier entry's — which happens here because a few older entries were
+ * manually bumped into the future — the migrator silently skips the new one.
+ *
+ * This script walks the journal in `idx` order. Any entry whose `when` is not
+ * strictly greater than the running max is rewritten to `max + 1`, so every
+ * subsequent migration is guaranteed to be picked up.
+ *
+ * Runs idempotently — no-op if the journal is already monotonic.
+ *
+ * Usage: `bun run scripts/fix-journal-ordering.ts` (wired into `db:generate`).
+ */
+
+import { readFileSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+
+const ROOT = join(import.meta.dir, "..");
+const JOURNAL_PATH = join(ROOT, "drizzle/meta/_journal.json");
+
+interface JournalEntry {
+ idx: number;
+ version: string;
+ when: number;
+ tag: string;
+ breakpoints: boolean;
+}
+
+interface Journal {
+ version: string;
+ dialect: string;
+ entries: JournalEntry[];
+}
+
+const raw = readFileSync(JOURNAL_PATH, "utf-8");
+const journal: Journal = JSON.parse(raw);
+
+// Sort by idx to be defensive — the file is already idx-ordered but nothing
+// enforces that.
+const entries = [...journal.entries].sort((a, b) => a.idx - b.idx);
+
+const bumps: { tag: string; from: number; to: number }[] = [];
+let maxWhen = Number.NEGATIVE_INFINITY;
+for (const entry of entries) {
+ if (entry.when > maxWhen) {
+ maxWhen = entry.when;
+ continue;
+ }
+ // Non-monotonic: bump to max + 1 so it's strictly greater than every prior.
+ const next = maxWhen + 1;
+ bumps.push({ tag: entry.tag, from: entry.when, to: next });
+ entry.when = next;
+ maxWhen = next;
+}
+
+if (bumps.length === 0) {
+ console.log("[fix-journal-ordering] journal already monotonic — nothing to do");
+ process.exit(0);
+}
+
+journal.entries = entries;
+// Preserve trailing newline — drizzle-kit writes one, and removing it churns diffs.
+const serialized = `${JSON.stringify(journal, null, 2)}\n`;
+writeFileSync(JOURNAL_PATH, serialized);
+
+console.log(
+ `[fix-journal-ordering] bumped ${bumps.length} entr${bumps.length === 1 ? "y" : "ies"}:`,
+);
+for (const b of bumps) {
+ console.log(` ${b.tag}: ${b.from} → ${b.to}`);
+}