diff --git a/packages/core/migration/20260531185402_jittery_blink/migration.sql b/packages/core/migration/20260531185402_jittery_blink/migration.sql new file mode 100644 index 0000000000..b9fb1b4427 --- /dev/null +++ b/packages/core/migration/20260531185402_jittery_blink/migration.sql @@ -0,0 +1,11 @@ +CREATE TABLE `project_path` ( + `project_id` text NOT NULL, + `path` text NOT NULL, + `primary` integer DEFAULT false NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + CONSTRAINT `project_path_pk` PRIMARY KEY(`project_id`, `path`), + CONSTRAINT `fk_project_path_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE UNIQUE INDEX `project_path_project_primary_idx` ON `project_path` (`project_id`) WHERE "project_path"."primary" = 1; \ No newline at end of file diff --git a/packages/core/migration/20260531185402_jittery_blink/snapshot.json b/packages/core/migration/20260531185402_jittery_blink/snapshot.json new file mode 100644 index 0000000000..a46aa33532 --- /dev/null +++ b/packages/core/migration/20260531185402_jittery_blink/snapshot.json @@ -0,0 +1,1727 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "0555502d-640a-4fba-97dc-ec9bfc2de2e3", + "prevIds": [ + "bf93c73b-5a48-4d63-9909-3c36a79b9788" + ], + "ddl": [ + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "project_path", + "entityType": "tables" + }, + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "project_path" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "project_path" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "primary", + "entityType": "columns", + "table": "project_path" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project_path" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project_path" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "session" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_project_path_project_id_project_id_fk", + "entityType": "fks", + "table": "project_path" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "project_id", + "path" + ], + "nameExplicit": false, + "name": "project_path_pk", + "entityType": "pks", + "table": "project_path" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": true, + "where": "\"project_path\".\"primary\" = 1", + "origin": "manual", + "name": "project_path_project_primary_idx", + "entityType": "indexes", + "table": "project_path" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 1a6918b33b..98cb9bb5c4 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -23,5 +23,6 @@ export const migrations = ( import("./migration/20260510033149_session_usage"), import("./migration/20260511000411_data_migration_state"), import("./migration/20260530232709_lovely_romulus"), + import("./migration/20260531185402_jittery_blink"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260531185402_jittery_blink.ts b/packages/core/src/database/migration/20260531185402_jittery_blink.ts new file mode 100644 index 0000000000..1cc0c35699 --- /dev/null +++ b/packages/core/src/database/migration/20260531185402_jittery_blink.ts @@ -0,0 +1,22 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260531185402_jittery_blink", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`project_path\` ( + \`project_id\` text NOT NULL, + \`path\` text NOT NULL, + \`primary\` integer DEFAULT false NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`project_path_pk\` PRIMARY KEY(\`project_id\`, \`path\`), + CONSTRAINT \`fk_project_path_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(`CREATE UNIQUE INDEX \`project_path_project_primary_idx\` ON \`project_path\` (\`project_id\`) WHERE "project_path"."primary" = 1;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index a574523368..fb7f548d12 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -1,7 +1,7 @@ export * as Git from "./git" import path from "path" -import { Context, Effect, Layer } from "effect" +import { Context, Effect, Layer, Schema } from "effect" import { ChildProcess } from "effect/unstable/process" import { AbsolutePath } from "./schema" import { AppFileSystem } from "./filesystem" @@ -26,10 +26,20 @@ export interface Repo { readonly store: AbsolutePath } +export class WorktreeError extends Schema.TaggedErrorClass()("Git.WorktreeError", { + operation: Schema.Literals(["create", "remove", "list"]), + message: Schema.String, + path: Schema.optional(AbsolutePath), + cause: Schema.optional(Schema.Defect), +}) {} + export interface Interface { readonly find: (input: AbsolutePath) => Effect.Effect readonly remote: (repo: Repo, name?: string) => Effect.Effect readonly roots: (repo: Repo) => Effect.Effect + readonly worktreeCreate: (input: { repo: Repo; path: AbsolutePath }) => Effect.Effect + readonly worktreeRemove: (input: { repo: Repo; path: AbsolutePath }) => Effect.Effect + readonly worktreeList: (repo: Repo) => Effect.Effect } export class Service extends Context.Service()("@opencode/GitV2") {} @@ -75,7 +85,43 @@ export const layer = Layer.effect( .toSorted() }) - return Service.of({ find, remote, roots }) + const worktree = Effect.fnUntraced(function* ( + operation: "create" | "remove" | "list", + repo: Repo, + args: string[], + worktreePath?: AbsolutePath, + ) { + const result = yield* proc + .run(ChildProcess.make("git", args, { cwd: repo.directory, extendEnv: true, stdin: "ignore" })) + .pipe( + Effect.mapError( + (cause) => new WorktreeError({ operation, path: worktreePath, message: cause.message, cause }), + ), + ) + if (result.exitCode === 0) return result.stdout.toString("utf8") + return yield* new WorktreeError({ + operation, + path: worktreePath, + message: result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed", + }) + }) + + const worktreeCreate = Effect.fn("Git.worktreeCreate")(function* (input: { repo: Repo; path: AbsolutePath }) { + yield* worktree("create", input.repo, ["worktree", "add", "--detach", input.path, "HEAD"], input.path) + }) + + const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: { repo: Repo; path: AbsolutePath }) { + yield* worktree("remove", input.repo, ["worktree", "remove", "--force", input.path], input.path) + }) + + const worktreeList = Effect.fn("Git.worktreeList")(function* (repo: Repo) { + return (yield* worktree("list", repo, ["worktree", "list", "--porcelain"])) + .split("\n") + .filter((line) => line.startsWith("worktree ")) + .map((line) => AbsolutePath.make(resolvePath(repo.directory, line.slice("worktree ".length).trim()))) + }) + + return Service.of({ find, remote, roots, worktreeCreate, worktreeRemove, worktreeList }) }), ) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index f71b828246..e6983f465c 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -2,11 +2,14 @@ export * as ProjectV2 from "./project" export * as Project from "./project" import { Context, Effect, Layer, Schema } from "effect" +import { eq } from "drizzle-orm" import path from "path" import { AbsolutePath, withStatics } from "./schema" +import { Database } from "./database/database" import { AppFileSystem } from "./filesystem" import { Git } from "./git" import { Hash } from "./util/hash" +import { ProjectPathTable } from "./project/path.sql" export const ID = Schema.String.pipe( Schema.brand("Project.ID"), @@ -28,7 +31,21 @@ export class Info extends Schema.Class("Project.Info")({ id: ID, }) {} +export const PathsInput = Schema.Struct({ + projectID: ID, +}).annotate({ identifier: "Project.PathsInput" }) +export type PathsInput = typeof PathsInput.Type + +export const Paths = Schema.Array( + Schema.Struct({ + path: AbsolutePath, + primary: Schema.Boolean, + }), +).annotate({ identifier: "Project.Paths" }) +export type Paths = typeof Paths.Type + export interface Interface { + readonly paths: (input: PathsInput) => Effect.Effect readonly resolve: (input: AbsolutePath) => Effect.Effect< { previous?: ID @@ -55,9 +72,22 @@ export class Service extends Context.Service()("@opencode/Pr export const layer = Layer.effect( Service, Effect.gen(function* () { + const db = (yield* Database.Service).db const fs = yield* AppFileSystem.Service const git = yield* Git.Service + const paths = Effect.fn("Project.paths")(function* (input: PathsInput) { + const rows = yield* db + .select({ path: ProjectPathTable.path, primary: ProjectPathTable.primary }) + .from(ProjectPathTable) + .where(eq(ProjectPathTable.project_id, input.projectID)) + .all() + .pipe(Effect.orDie) + return rows + .toSorted((a, b) => a.path.localeCompare(b.path)) + .map((row) => ({ path: AbsolutePath.make(row.path), primary: row.primary })) + }) + const cached = Effect.fnUntraced(function* (dir: string) { return yield* fs.readFileString(path.join(dir, "opencode")).pipe( Effect.map((value) => value.trim()), @@ -122,8 +152,12 @@ export const layer = Layer.effect( yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore) }) - return Service.of({ resolve, commit }) + return Service.of({ paths, resolve, commit }) }), ) -export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(Database.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Git.defaultLayer), +) diff --git a/packages/core/src/project/copy-strategies.ts b/packages/core/src/project/copy-strategies.ts new file mode 100644 index 0000000000..40ebaec2e1 --- /dev/null +++ b/packages/core/src/project/copy-strategies.ts @@ -0,0 +1,42 @@ +import { Effect } from "effect" +import { AbsolutePath } from "../schema" +import { Git } from "../git" +import { type Copy, type Strategy, type StrategyID } from "./copy" +import type { PathUnavailableError } from "./copy" + +export function makeStrategies(input: { + git: Git.Interface + canonical: (path: AbsolutePath) => Effect.Effect +}) { + const repo = (primaryPath: AbsolutePath) => ({ directory: primaryPath, store: primaryPath } satisfies Git.Repo) + + const gitWorktree: Strategy = { + id: "git_worktree", + name: "Git worktree", + create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) { + yield* input.git.worktreeCreate({ repo: repo(options.primaryPath), path: options.path }) + return { path: yield* input.canonical(options.path) } + }), + remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) { + const entries = yield* gitWorktree.list(options) + if (!entries.some((entry) => entry.path === options.path)) { + return yield* new Git.WorktreeError({ + operation: "remove", + path: options.path, + message: "Path is not a linked Git worktree", + }) + } + yield* input.git.worktreeRemove({ repo: repo(options.primaryPath), path: options.path }) + }), + list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (options) { + const entries = yield* input.git.worktreeList(repo(options.primaryPath)) + return yield* Effect.forEach(entries, (entry) => + entry === options.primaryPath + ? Effect.succeed(undefined) + : input.canonical(entry).pipe(Effect.map((path) => ({ path }))), + ).pipe(Effect.map((items) => items.filter((item): item is Copy => item !== undefined))) + }), + } + + return new Map([[gitWorktree.id, gitWorktree]]) +} diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts new file mode 100644 index 0000000000..e28a024d97 --- /dev/null +++ b/packages/core/src/project/copy.ts @@ -0,0 +1,236 @@ +export * as ProjectCopy from "./copy" + +import { and, eq } from "drizzle-orm" +import { Context, Effect, Layer, Schema } from "effect" +import { AbsolutePath } from "../schema" +import { AppFileSystem } from "../filesystem" +import { Git } from "../git" +import { Database } from "../database/database" +import { EventV2 } from "../event" +import { Project } from "../project" +import { ProjectPathTable } from "./path.sql" +import { makeStrategies } from "./copy-strategies" + +export const StrategyID = Schema.Literal("git_worktree") +export type StrategyID = typeof StrategyID.Type + +export const StrategiesInput = Schema.Struct({ + projectID: Project.ID, +}).annotate({ identifier: "ProjectCopy.StrategiesInput" }) +export type StrategiesInput = typeof StrategiesInput.Type + +export const CreateInput = Schema.Struct({ + projectID: Project.ID, + strategy: StrategyID, + path: AbsolutePath, +}).annotate({ identifier: "ProjectCopy.CreateInput" }) +export type CreateInput = typeof CreateInput.Type + +export const RemoveInput = CreateInput.annotate({ identifier: "ProjectCopy.RemoveInput" }) +export type RemoveInput = typeof RemoveInput.Type + +export const RefreshInput = Schema.Struct({ + projectID: Project.ID, + strategy: Schema.optional(StrategyID), +}).annotate({ identifier: "ProjectCopy.RefreshInput" }) +export type RefreshInput = typeof RefreshInput.Type + +export const StrategyInfo = Schema.Struct({ + id: StrategyID, + name: Schema.String, +}).annotate({ identifier: "ProjectCopy.StrategyInfo" }) +export type StrategyInfo = typeof StrategyInfo.Type + +export const Copy = Schema.Struct({ + path: AbsolutePath, +}).annotate({ identifier: "ProjectCopy.Copy" }) +export type Copy = typeof Copy.Type + +export class PrimaryPathNotFoundError extends Schema.TaggedErrorClass()( + "ProjectCopy.PrimaryPathNotFoundError", + { projectID: Project.ID }, +) {} + +export class CannotRemovePrimaryPathError extends Schema.TaggedErrorClass()( + "ProjectCopy.CannotRemovePrimaryPathError", + { projectID: Project.ID, path: AbsolutePath }, +) {} + +export class DestinationExistsError extends Schema.TaggedErrorClass()( + "ProjectCopy.DestinationExistsError", + { path: AbsolutePath }, +) {} + +export class PathUnavailableError extends Schema.TaggedErrorClass()( + "ProjectCopy.PathUnavailableError", + { path: AbsolutePath }, +) {} + +export type Error = + | PrimaryPathNotFoundError + | CannotRemovePrimaryPathError + | DestinationExistsError + | PathUnavailableError + | Git.WorktreeError + +export interface Strategy { + readonly id: StrategyID + readonly name: string + readonly create: (input: { + projectID: Project.ID + primaryPath: AbsolutePath + path: AbsolutePath + }) => Effect.Effect + readonly remove: (input: { + projectID: Project.ID + primaryPath: AbsolutePath + path: AbsolutePath + }) => Effect.Effect + readonly list: (input: { + projectID: Project.ID + primaryPath: AbsolutePath + }) => Effect.Effect +} + +export const Event = { + Updated: EventV2.define({ + type: "project.paths.updated", + schema: { projectID: Project.ID }, + }), +} + +export interface Interface { + readonly strategies: (input: StrategiesInput) => Effect.Effect + readonly create: (input: CreateInput) => Effect.Effect + readonly remove: (input: RemoveInput) => Effect.Effect + readonly refresh: (input: RefreshInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/ProjectCopy") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + const events = yield* EventV2.Service + const db = (yield* Database.Service).db + + const canonical = Effect.fnUntraced(function* (input: AbsolutePath) { + const real = yield* fs.realPath(input).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!real || !(yield* fs.isDir(real))) return yield* new PathUnavailableError({ path: input }) + return AbsolutePath.make(AppFileSystem.normalizePath(real)) + }) + + const registry = makeStrategies({ git, canonical }) + + const primary = Effect.fnUntraced(function* (projectID: Project.ID) { + const row = yield* db + .select({ path: ProjectPathTable.path }) + .from(ProjectPathTable) + .where(and(eq(ProjectPathTable.project_id, projectID), eq(ProjectPathTable.primary, true))) + .get() + .pipe(Effect.orDie) + if (!row) return yield* new PrimaryPathNotFoundError({ projectID }) + return yield* canonical(AbsolutePath.make(row.path)) + }) + + const insert = Effect.fnUntraced(function* (projectID: Project.ID, copyPath: AbsolutePath) { + return yield* db + .transaction( + (tx) => + Effect.gen(function* () { + const row = yield* tx + .select({ path: ProjectPathTable.path }) + .from(ProjectPathTable) + .where(and(eq(ProjectPathTable.project_id, projectID), eq(ProjectPathTable.path, copyPath))) + .get() + if (row) return false + yield* tx.insert(ProjectPathTable).values({ project_id: projectID, path: copyPath, primary: false }).run() + return true + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + }) + + const removeStored = Effect.fnUntraced(function* (projectID: Project.ID, copyPath: AbsolutePath) { + const row = yield* db + .select({ path: ProjectPathTable.path }) + .from(ProjectPathTable) + .where(and(eq(ProjectPathTable.project_id, projectID), eq(ProjectPathTable.path, copyPath))) + .get() + .pipe(Effect.orDie) + if (!row) return false + yield* db + .delete(ProjectPathTable) + .where(and(eq(ProjectPathTable.project_id, projectID), eq(ProjectPathTable.path, copyPath))) + .run() + .pipe(Effect.orDie) + return true + }) + + const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) { + if (update) yield* events.publish(Event.Updated, { projectID }) + }) + + const strategy = (id: StrategyID) => registry.get(id) as Strategy + + const strategies = Effect.fn("ProjectCopy.strategies")(function* (_input: StrategiesInput) { + return Array.from(registry.values()).map((strategy) => ({ id: strategy.id, name: strategy.name })) + }) + + const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) { + if (yield* fs.existsSafe(input.path)) return yield* new DestinationExistsError({ path: input.path }) + const result = yield* strategy(input.strategy).create({ ...input, primaryPath: yield* primary(input.projectID) }) + yield* changed(input.projectID, yield* insert(input.projectID, result.path)) + return result + }) + + const remove = Effect.fn("ProjectCopy.remove")(function* (input: RemoveInput) { + const primaryPath = yield* primary(input.projectID) + const copyPath = yield* canonical(input.path) + if (copyPath === primaryPath) { + return yield* new CannotRemovePrimaryPathError({ projectID: input.projectID, path: copyPath }) + } + yield* strategy(input.strategy).remove({ ...input, path: copyPath, primaryPath }) + yield* changed(input.projectID, yield* removeStored(input.projectID, copyPath)) + }) + + const refresh = Effect.fn("ProjectCopy.refresh")(function* (input: RefreshInput) { + const primaryPath = yield* primary(input.projectID) + const strategies = input.strategy ? [strategy(input.strategy)] : Array.from(registry.values()) + const discovered = yield* Effect.forEach(strategies, (strategy) => strategy.list({ ...input, primaryPath }), { + concurrency: "unbounded", + }).pipe(Effect.map((sets) => new Set(sets.flatMap((set) => set.map((item) => item.path))))) + const stored = yield* db + .select({ path: ProjectPathTable.path, primary: ProjectPathTable.primary }) + .from(ProjectPathTable) + .where(eq(ProjectPathTable.project_id, input.projectID)) + .all() + .pipe(Effect.orDie) + const inserted = yield* Effect.forEach(discovered, (copyPath) => insert(input.projectID, copyPath)).pipe( + Effect.map((items) => items.some(Boolean)), + ) + const removed = yield* Effect.forEach( + stored.filter((item) => !item.primary), + (item) => + fs.isDir(item.path).pipe( + Effect.flatMap((exists) => + exists ? Effect.succeed(false) : removeStored(input.projectID, AbsolutePath.make(item.path)), + ), + ), + ).pipe(Effect.map((items) => items.some(Boolean))) + yield* changed(input.projectID, inserted || removed) + }) + + return Service.of({ strategies, create, remove, refresh }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Database.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Git.defaultLayer), + Layer.provide(EventV2.defaultLayer), +) diff --git a/packages/core/src/project/path.sql.ts b/packages/core/src/project/path.sql.ts new file mode 100644 index 0000000000..0fa60be738 --- /dev/null +++ b/packages/core/src/project/path.sql.ts @@ -0,0 +1,24 @@ +import { sql } from "drizzle-orm" +import { integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core" +import { Timestamps } from "../database/schema.sql" +import { ProjectTable } from "./sql" +import { ProjectV2 } from "../project" + +export const ProjectPathTable = sqliteTable( + "project_path", + { + project_id: text() + .$type() + .notNull() + .references(() => ProjectTable.id, { onDelete: "cascade" }), + path: text().notNull(), + primary: integer({ mode: "boolean" }).notNull().default(false), + ...Timestamps, + }, + (table) => [ + primaryKey({ columns: [table.project_id, table.path] }), + uniqueIndex("project_path_project_primary_idx") + .on(table.project_id) + .where(sql`${table.primary} = 1`), + ], +) diff --git a/packages/core/test/git.test.ts b/packages/core/test/git.test.ts new file mode 100644 index 0000000000..1e7921c93e --- /dev/null +++ b/packages/core/test/git.test.ts @@ -0,0 +1,44 @@ +import { describe, expect } from "bun:test" +import { $ } from "bun" +import fs from "fs/promises" +import { Effect } from "effect" +import { Git } from "@opencode-ai/core/git" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const it = testEffect(Git.defaultLayer) + +async function initRepo(directory: string) { + await $`git init`.cwd(directory).quiet() + await $`git config core.fsmonitor false`.cwd(directory).quiet() + await $`git config commit.gpgsign false`.cwd(directory).quiet() + await $`git config user.email test@opencode.test`.cwd(directory).quiet() + await $`git config user.name Test`.cwd(directory).quiet() + await $`git commit --allow-empty -m root`.cwd(directory).quiet() +} + +describe("Git worktrees", () => { + it.live("creates, lists, and removes linked worktrees", () => + Effect.gen(function* () { + const root = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => initRepo(root.path)) + const directory = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(root.path))) + const worktree = AbsolutePath.make(`${root.path}-git-worktree`) + yield* Effect.addFinalizer(() => + Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })).pipe(Effect.ignore), + ) + const git = yield* Git.Service + const repo = { directory, store: AbsolutePath.make(`${directory}/.git`) } + + yield* git.worktreeCreate({ repo, path: worktree }) + + expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(true) + yield* git.worktreeRemove({ repo, path: worktree }) + expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(false) + }), + ) +}) diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index 305083bfed..f6994d4210 100644 --- a/packages/core/test/location.test.ts +++ b/packages/core/test/location.test.ts @@ -9,6 +9,7 @@ const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID: " const projectLayer = Layer.succeed( Project.Service, Project.Service.of({ + paths: () => Effect.succeed([]), resolve: () => Effect.succeed({ id: Project.ID.make("project"), diff --git a/packages/core/test/project-copy.test.ts b/packages/core/test/project-copy.test.ts new file mode 100644 index 0000000000..b42adf9d3b --- /dev/null +++ b/packages/core/test/project-copy.test.ts @@ -0,0 +1,149 @@ +import { describe, expect } from "bun:test" +import { $ } from "bun" +import fs from "fs/promises" +import path from "path" +import { eq } from "drizzle-orm" +import { Effect, Fiber, Layer, Stream } from "effect" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Git } from "@opencode-ai/core/git" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { Project } from "@opencode-ai/core/project" +import { ProjectPathTable } from "@opencode-ai/core/project/path.sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { ProjectCopy } from "@opencode-ai/core/project/copy" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const databaseLayer = Database.layerFromPath(":memory:") +const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer)) +const copyLayer = ProjectCopy.layer.pipe( + Layer.provide(databaseLayer), + Layer.provide(eventLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Git.defaultLayer), +) +const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer)) + +function abs(input: string) { + return AbsolutePath.make(input) +} + +async function initRepo(directory: string) { + await $`git init`.cwd(directory).quiet() + await $`git config core.fsmonitor false`.cwd(directory).quiet() + await $`git config commit.gpgsign false`.cwd(directory).quiet() + await $`git config user.email test@opencode.test`.cwd(directory).quiet() + await $`git config user.name Test`.cwd(directory).quiet() + await $`git commit --allow-empty -m root`.cwd(directory).quiet() +} + +function setup() { + return Effect.gen(function* () { + const root = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => initRepo(root.path)) + const primaryPath = abs(yield* Effect.promise(() => fs.realpath(root.path))) + const projectID = Project.ID.make("copy-project") + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: projectID, worktree: root.path, sandboxes: [], time_created: 1, time_updated: 1 }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(ProjectPathTable) + .values({ project_id: projectID, path: primaryPath, primary: true }) + .run() + .pipe(Effect.orDie) + return { root, primaryPath, projectID, db } + }) +} + +function stored(projectID: Project.ID) { + return Database.Service.use(({ db }) => + db + .select({ path: ProjectPathTable.path, primary: ProjectPathTable.primary }) + .from(ProjectPathTable) + .where(eq(ProjectPathTable.project_id, projectID)) + .all() + .pipe(Effect.orDie, Effect.map((rows) => rows.toSorted((a, b) => a.path.localeCompare(b.path)))), + ) +} + +describe("ProjectCopy", () => { + it.effect("lists the initial git worktree strategy", () => + Effect.gen(function* () { + const copy = yield* ProjectCopy.Service + + expect(yield* copy.strategies({ projectID: Project.ID.make("project") })).toEqual([ + { id: "git_worktree", name: "Git worktree" }, + ]) + }), + ) + + it.live("creates and removes a git worktree path", () => + Effect.gen(function* () { + const input = yield* setup() + const copy = yield* ProjectCopy.Service + const target = abs(`${input.root.path}-copy-created`) + yield* Effect.addFinalizer(() => + Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore), + ) + + const created = yield* copy.create({ projectID: input.projectID, strategy: "git_worktree", path: target }) + expect(yield* stored(input.projectID)).toEqual([ + { path: input.primaryPath, primary: true }, + { path: created.path, primary: false }, + ].toSorted((a, b) => a.path.localeCompare(b.path))) + + yield* copy.remove({ projectID: input.projectID, strategy: "git_worktree", path: created.path }) + + expect(yield* stored(input.projectID)).toEqual([{ path: input.primaryPath, primary: true }]) + expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false) + }), + ) + + it.live("refresh discovers and prunes an externally managed git worktree", () => + Effect.gen(function* () { + const input = yield* setup() + const copy = yield* ProjectCopy.Service + const events = yield* EventV2.Service + const target = abs(`${input.root.path}-copy-external`) + yield* Effect.addFinalizer(() => + Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore), + ) + yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet()) + const fiber = yield* events.subscribe(ProjectCopy.Event.Updated).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + yield* Effect.yieldNow + + yield* copy.refresh({ projectID: input.projectID, strategy: "git_worktree" }) + + const discovered = abs(yield* Effect.promise(() => fs.realpath(target))) + expect(yield* stored(input.projectID)).toEqual([ + { path: input.primaryPath, primary: true }, + { path: discovered, primary: false }, + ].toSorted((a, b) => a.path.localeCompare(b.path))) + expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID }) + + yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet()) + yield* copy.refresh({ projectID: input.projectID, strategy: "git_worktree" }) + expect(yield* stored(input.projectID)).toEqual([{ path: input.primaryPath, primary: true }]) + }), + ) + + it.live("refuses to remove the primary project path", () => + Effect.gen(function* () { + const input = yield* setup() + const copy = yield* ProjectCopy.Service + const result = yield* Effect.exit( + copy.remove({ projectID: input.projectID, strategy: "git_worktree", path: input.primaryPath }), + ) + + expect(result._tag).toBe("Failure") + }), + ) +}) diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 94ea40f672..6ac8a460d1 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -2,14 +2,30 @@ import { describe, expect } from "bun:test" import { $ } from "bun" import fs from "fs/promises" import path from "path" -import { Effect } from "effect" +import { Effect, Layer, Schema } from "effect" import { ProjectV2 } from "@opencode-ai/core/project" +import { ProjectPathTable } from "@opencode-ai/core/project/path.sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { Database } from "@opencode-ai/core/database/database" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Git } from "@opencode-ai/core/git" import { AbsolutePath } from "@opencode-ai/core/schema" import { Hash } from "@opencode-ai/core/util/hash" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" const it = testEffect(ProjectV2.defaultLayer) +const databaseLayer = Database.layerFromPath(":memory:") +const pathsIt = testEffect( + Layer.mergeAll( + ProjectV2.layer.pipe( + Layer.provide(databaseLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Git.defaultLayer), + ), + databaseLayer, + ), +) function remoteID(remote: string) { return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`)) @@ -37,6 +53,50 @@ async function rootCommit(dir: string) { return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim() } +describe("Project paths schemas", () => { + it.effect("decodes project path input and inline path results", () => + Effect.sync(() => { + expect(Schema.decodeUnknownSync(ProjectV2.PathsInput)({ projectID: ProjectV2.ID.make("project") })).toEqual({ + projectID: ProjectV2.ID.make("project"), + }) + expect( + Schema.decodeUnknownSync(ProjectV2.Paths)([{ path: AbsolutePath.make("/tmp/project"), primary: true }]), + ).toEqual([{ path: AbsolutePath.make("/tmp/project"), primary: true }]) + }), + ) + + pathsIt.effect("lists stored project paths only for the requested project", () => + Effect.gen(function* () { + const project = yield* ProjectV2.Service + const { db } = yield* Database.Service + const projectID = ProjectV2.ID.make("paths-project") + const otherID = ProjectV2.ID.make("paths-other") + yield* db + .insert(ProjectTable) + .values([ + { id: projectID, worktree: "/repo", sandboxes: [], time_created: 1, time_updated: 1 }, + { id: otherID, worktree: "/other", sandboxes: [], time_created: 1, time_updated: 1 }, + ]) + .run() + .pipe(Effect.orDie) + yield* db + .insert(ProjectPathTable) + .values([ + { project_id: projectID, path: AbsolutePath.make("/repo/z"), primary: false }, + { project_id: projectID, path: AbsolutePath.make("/repo/a"), primary: true }, + { project_id: otherID, path: AbsolutePath.make("/other"), primary: true }, + ]) + .run() + .pipe(Effect.orDie) + + expect(yield* project.paths({ projectID })).toEqual([ + { path: AbsolutePath.make("/repo/a"), primary: true }, + { path: AbsolutePath.make("/repo/z"), primary: false }, + ]) + }), + ) +}) + describe("ProjectV2.resolve", () => { it.live("returns global for non-git directory", () => Effect.gen(function* () { diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 35f58f6666..747567f1c5 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -1,6 +1,7 @@ import { and, eq, sql } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { ProjectTable } from "@opencode-ai/core/project/sql" +import { ProjectPathTable } from "@opencode-ai/core/project/path.sql" import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql" import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import * as Log from "@opencode-ai/core/util/log" @@ -249,6 +250,56 @@ export const layer = Layer.effect( .pipe(Effect.orDie) }) + const rememberProjectPath = Effect.fn("Project.rememberProjectPath")(function* (input: { + projectID: ProjectV2.ID + path: string + }) { + if (input.projectID === ProjectV2.ID.global) return + + yield* db + .transaction( + (d) => + Effect.gen(function* () { + const existing = yield* d + .select() + .from(ProjectPathTable) + .where(and(eq(ProjectPathTable.project_id, input.projectID), eq(ProjectPathTable.path, input.path))) + .get() + const primary = yield* d + .select({ path: ProjectPathTable.path }) + .from(ProjectPathTable) + .where(and(eq(ProjectPathTable.project_id, input.projectID), eq(ProjectPathTable.primary, true))) + .get() + + if (existing) { + if (!primary) { + yield* d + .update(ProjectPathTable) + .set({ primary: true }) + .where(and(eq(ProjectPathTable.project_id, input.projectID), eq(ProjectPathTable.path, input.path))) + .run() + } + return + } + + yield* d + .insert(ProjectPathTable) + .values({ + path: input.path, + primary: !primary, + project_id: input.projectID, + }) + .run() + }), + { behavior: "immediate" }, + ) + .pipe( + Effect.catchCause((cause) => + Effect.sync(() => log.warn("project path persistence failed", { projectID: input.projectID, cause })), + ), + ) + }) + const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) { log.info("fromDirectory", { directory }) @@ -336,6 +387,11 @@ export const layer = Layer.effect( .pipe(Effect.orDie) } + yield* rememberProjectPath({ + projectID, + path: data.directory, + }) + yield* emitUpdated(result) if (projectID !== ProjectV2.ID.global && data.vcs?.type === "git") { yield* projectV2.commit({ store: data.vcs.store, id: data.id }) diff --git a/packages/opencode/test/project/project-path.test.ts b/packages/opencode/test/project/project-path.test.ts new file mode 100644 index 0000000000..c1b1fd3f18 --- /dev/null +++ b/packages/opencode/test/project/project-path.test.ts @@ -0,0 +1,128 @@ +import { describe, expect } from "bun:test" +import { $ } from "bun" +import path from "path" +import { eq } from "drizzle-orm" +import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Hash } from "@opencode-ai/core/util/hash" +import { Database } from "@opencode-ai/core/database/database" +import { ProjectPathTable } from "@opencode-ai/core/project/path.sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { ProjectV2 } from "@opencode-ai/core/project" +import { Project } from "@/project/project" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(Project.defaultLayer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer)) + +function paths(projectID: ProjectV2.ID) { + return Database.Service.use(({ db }) => + db + .select() + .from(ProjectPathTable) + .where(eq(ProjectPathTable.project_id, projectID)) + .all() + .pipe( + Effect.orDie, + Effect.map((rows) => + rows.map((row) => ({ path: row.path, primary: row.primary })).toSorted((a, b) => a.path.localeCompare(b.path)), + ), + ), + ) +} + +describe("Project path persistence", () => { + it.live("stores the first opened checkout as the primary path", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped({ git: true }) + const project = yield* Project.Service + + const result = yield* project.fromDirectory(tmp) + + expect(yield* paths(result.project.id)).toEqual([{ path: tmp, primary: true }]) + }), + ) + + it.live("stores an opened linked worktree as a secondary path", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped({ git: true }) + const project = yield* Project.Service + const main = yield* project.fromDirectory(tmp) + const worktree = path.join(tmp, "..", path.basename(tmp) + "-project-path-worktree") + yield* Effect.addFinalizer(() => + Effect.promise(() => $`git worktree remove ${worktree}`.cwd(tmp).quiet().nothrow()).pipe(Effect.ignore), + ) + yield* Effect.promise(() => $`git worktree add ${worktree} -b project-path-${Date.now()}`.cwd(tmp).quiet()) + + yield* project.fromDirectory(worktree) + + expect(yield* paths(main.project.id)).toEqual( + [ + { path: tmp, primary: true }, + { path: worktree, primary: false }, + ].toSorted((a, b) => a.path.localeCompare(b.path)), + ) + }), + ) + + it.live("stores a separately opened clone as a secondary path", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped({ git: true }) + const bare = tmp + "-project-path-bare" + const clone = tmp + "-project-path-clone" + yield* Effect.addFinalizer(() => + Effect.promise(() => $`rm -rf ${bare} ${clone}`.quiet().nothrow()).pipe(Effect.ignore), + ) + yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet()) + yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet()) + const project = yield* Project.Service + const main = yield* project.fromDirectory(tmp) + + yield* project.fromDirectory(clone) + + expect(yield* paths(main.project.id)).toEqual( + [ + { path: tmp, primary: true }, + { path: clone, primary: false }, + ].toSorted((a, b) => a.path.localeCompare(b.path)), + ) + }), + ) + + it.live("keeps an existing remote project primary when migrating an inferred project id", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped({ git: true }) + const project = yield* Project.Service + yield* project.fromDirectory(tmp) + const remoteID = ProjectV2.ID.make(Hash.fast("git-remote:github.com/project-path-test/collision")) + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ + id: remoteID, + worktree: "/tmp/existing", + vcs: "git", + time_created: Date.now(), + time_updated: Date.now(), + sandboxes: [], + }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(ProjectPathTable) + .values({ project_id: remoteID, path: "/tmp/existing", primary: true }) + .run() + .pipe(Effect.orDie) + yield* Effect.promise(() => $`git remote add origin git@github.com:project-path-test/collision.git`.cwd(tmp).quiet()) + + yield* project.fromDirectory(tmp) + + expect(yield* paths(remoteID)).toEqual( + [ + { path: "/tmp/existing", primary: true }, + { path: tmp, primary: false }, + ].toSorted((a, b) => a.path.localeCompare(b.path)), + ) + }), + ) +})