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}`); +}