diff --git a/bin/si/deno.json b/bin/si/deno.json index f173478aff..80d32eea40 100644 --- a/bin/si/deno.json +++ b/bin/si/deno.json @@ -14,7 +14,7 @@ }, "workspace": [], "tasks": { - "dev": "deno run --allow-net --allow-env --allow-read --env-file=.env.local --allow-write --watch main.ts", + "dev": "deno run --unstable-sloppy-imports --allow-net --allow-env --allow-read --env-file=.env.local --allow-write --watch main.ts", "build": "deno compile --allow-net --allow-env --allow-read --allow-write main.ts", "lint": "deno lint", "test": "deno test --allow-env --allow-read --allow-write" diff --git a/bin/si/deno.lock b/bin/si/deno.lock index b24e585f56..0fdb294a77 100644 --- a/bin/si/deno.lock +++ b/bin/si/deno.lock @@ -28,6 +28,8 @@ "jsr:@std/text@~1.0.7": "1.0.16", "jsr:@std/ulid@1": "1.0.0", "jsr:@std/yaml@^1.0.5": "1.0.10", + "jsr:@systeminit/api-client@^1.9.0": "1.9.0", + "npm:@clack/prompts@*": "0.11.0", "npm:@types/node@24.0.7": "24.0.7", "npm:axios@1.11.0": "1.11.0", "npm:axios@^1.13.1": "1.13.2", @@ -35,6 +37,7 @@ "npm:ejs@^3.1.10": "3.1.10", "npm:jwt-decode@4": "4.0.0", "npm:path-to-regexp@8.2.0": "8.2.0", + "npm:picocolors@*": "1.1.1", "npm:posthog-node@^4.2.1": "4.18.0", "npm:zod@4": "4.1.12" }, @@ -164,9 +167,30 @@ }, "@std/yaml@1.0.10": { "integrity": "245706ea3511cc50c8c6d00339c23ea2ffa27bd2c7ea5445338f8feff31fa58e" + }, + "@systeminit/api-client@1.9.0": { + "integrity": "35a7fcb52522bcdc7f2843d5c041f44e35b67a1ae2f2b336bcf285d3c5f74b08", + "dependencies": [ + "npm:axios@^1.6.1" + ] } }, "npm": { + "@clack/core@0.5.0": { + "integrity": "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==", + "dependencies": [ + "picocolors", + "sisteransi" + ] + }, + "@clack/prompts@0.11.0": { + "integrity": "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==", + "dependencies": [ + "@clack/core", + "picocolors", + "sisteransi" + ] + }, "@types/node@24.0.7": { "integrity": "sha512-YIEUUr4yf8q8oQoXPpSlnvKNVKDQlPMWrmOcgzoduo7kvA2UF0/BwJ/eMKFTiTtkNL17I0M6Xe2tvwFU7be6iw==", "dependencies": [ @@ -361,6 +385,9 @@ "proxy-from-env@1.1.0": { "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, + "sisteransi@1.0.5": { + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" + }, "undici-types@7.8.0": { "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==" }, diff --git a/bin/si/src/cli.ts b/bin/si/src/cli.ts index e1e92ab47a..9b386fe8ed 100644 --- a/bin/si/src/cli.ts +++ b/bin/si/src/cli.ts @@ -40,6 +40,7 @@ import { type ComponentGetOptions } from "./component/get.ts"; import { type ComponentUpdateOptions } from "./component/update.ts"; import { type ComponentDeleteOptions } from "./component/delete.ts"; import { type ComponentSearchOptions } from "./component/search.ts"; +import { callTui } from "./command/tui.ts"; /** Current version of the SI CLI */ const VERSION = "0.1.0"; @@ -175,7 +176,9 @@ function buildCommand() { // deno-lint-ignore no-explicit-any .command("template", buildTemplateCommand() as any) // deno-lint-ignore no-explicit-any - .command("whoami", buildWhoamiCommand() as any); + .command("whoami", buildWhoamiCommand() as any) + // deno-lint-ignore no-explicit-any + .command("tui", buildTuiCommand() as any); } /** @@ -267,7 +270,7 @@ function buildRemoteSchemaCommand() { "--builtins", "Include builtin schemas (schemas you don't own). By default, builtins are skipped.", ) - .action(async ({ root, apiBaseUrl, apiToken, builtins }, ...schemaNames) => { + .action(async ({ root, apiBaseUrl, apiToken }, ...schemaNames) => { const project = createProject(root); const apiCtx = await createApiContext(apiBaseUrl, apiToken); let finalSchemaNames; @@ -282,7 +285,6 @@ function buildRemoteSchemaCommand() { project, apiCtx, finalSchemaNames, - builtins ?? false, ); }), ) @@ -355,6 +357,22 @@ function buildWhoamiCommand() { }); } +/** + * Builds the tui command. + * + * @returns A SubCommand to start the TUI + * @internal + */ +function buildTuiCommand() { + return createSubCommand() + .description("Starts the TUI") + .action(async ({ apiBaseUrl, apiToken }) => { + const apiCtx = await createApiContext(apiBaseUrl, apiToken); + + await callTui(Context.instance(), apiCtx); + }); +} + /** * Builds the project init subcommands. * diff --git a/bin/si/src/command/tui.ts b/bin/si/src/command/tui.ts new file mode 100644 index 0000000000..6ab7f6a776 --- /dev/null +++ b/bin/si/src/command/tui.ts @@ -0,0 +1,118 @@ +import { Context } from "../context.ts"; +import { ApiContext } from "../api.ts"; +import * as p from 'npm:@clack/prompts'; +import color from 'npm:picocolors'; +import { apiConfig } from "../si_client.ts"; +// import { ChangeSetsApi, ChangeSetViewV1, MvApi } from "https://jsr.io/@systeminit/api-client/1.9.0/api.ts"; +import { ChangeSetsApi, ChangeSetViewV1, MvApi } from "../../../../generated-sdks/typescript/api.ts"; + +const EXIT = "-1"; + +export async function callTui(ctx: Context, _apiCtx: ApiContext) { + const changeSetsApi = new ChangeSetsApi(apiConfig); + const mvSetsApi = new MvApi(apiConfig); + const workspaceId = ctx.workspaceId; + if (!workspaceId) throw new Error("No Workspace"); + + p.updateSettings({ + aliases: { + w: 'up', + s: 'down', + a: 'left', + d: 'right', + }, + }); + + p.intro("Welcome! Let's review propsed changes in your workspace") + + const cs = p.spinner({ + onCancel: () => { + process.exit(0); + } + }); + cs.start(`${color.bgBlack(color.greenBright("Retrieving change sets"))}`); + const response = await changeSetsApi.listChangeSets({ workspaceId }); + const changeSets = response.data.changeSets as ChangeSetViewV1[]; + cs.stop(); + + const options = changeSets.filter((c) => !c.isHead).map((c) => { + return { + value: c.id, + label: c.name, + } + }) + const changeSetId = await p.select({ + message: "Choose a change set:", + options, + }); + + if (p.isCancel(changeSetId)) { + p.cancel("Cancelled, exiting...") + process.exit(0); + } + + const c = p.spinner(); + + c.start(`${color.bgBlack(color.greenBright("Retrieving components"))}`); + const componentList = await mvSetsApi.get({ + workspaceId, + changeSetId, + entityId: workspaceId, + kind: "ComponentList" + }) + + // N+1 requests are horribly in-efficient + // if we wanted to invest in a TUI we could do the same + // "sync all the MVs" on start, open a web socket, etc + const componentDetails = await Promise.all(componentList.data.data.components.map((c) => { + return mvSetsApi.get({ + workspaceId, + changeSetId, + entityId: c.id, + kind: "ComponentInList" + }) + })); + c.stop(); + + const componentOptions = componentDetails.filter((req) => { + const c = req.data.data; + return c.diffStatus !== "None"; + }).map((req) => { + const c = req.data.data; + return { + value: c.id, + label: c.name, + } + }); + + if (componentOptions.length === 0) { + p.outro(`${color.bgBlack(color.redBright("There are no modifications on this simulated change set."))}`); + p.outro("Goodbye!"); + process.exit(0); + } + componentOptions.unshift({value: EXIT, label: "[quit]"}) + + while (true) { + const componentId = await p.select({ + message: "Choose a modified component to review:", + options: componentOptions, + }); + if (p.isCancel(componentId)) { + p.cancel("Cancelled, exiting...") + process.exit(0); + } + if (componentId === EXIT) break; + + const diff = await mvSetsApi.get({ + workspaceId, + changeSetId, + entityId: componentId, + kind: "ComponentDiff" + }); + + p.outro(`Here is what changed:`) + console.log(JSON.stringify(diff.data.data, undefined, 2)); + } + + p.outro("Goodbye"); +} diff --git a/lib/luminork-server/src/service/v1.rs b/lib/luminork-server/src/service/v1.rs index ffa8edd0c0..8ff176bb3a 100644 --- a/lib/luminork-server/src/service/v1.rs +++ b/lib/luminork-server/src/service/v1.rs @@ -10,6 +10,7 @@ mod components; mod debug_funcs; mod funcs; mod management_funcs; +mod mv; mod schemas; mod search; mod secrets; @@ -132,6 +133,7 @@ pub use management_funcs::{ ManagementFuncsResult, get_management_func_run_state::GetManagementFuncJobStateV1Response, }; +pub use mv::get::MvResponse; pub use schemas::{ DetachFuncBindingV1Response, GetSchemaV1Response, @@ -266,6 +268,7 @@ pub use crate::api_types::func_run::v1::{ secrets::update_secret::update_secret, secrets::get_secrets::get_secrets, search::search, + mv::get::get, ), components( schemas( @@ -364,6 +367,7 @@ pub use crate::api_types::func_run::v1::{ ExecDebugFuncV1Request, ExecDebugFuncV1Response, GetDebugFuncJobStateV1Response, + MvResponse, ) ), tags( @@ -375,7 +379,8 @@ pub use crate::api_types::func_run::v1::{ (name = "secrets", description = "Secret management endpoints"), (name = "funcs", description = "Functions management endpoints"), (name = "debug_funcs", description = "Debug function endpoints"), - (name = "management_funcs", description = "Management functions endpoints") + (name = "management_funcs", description = "Management functions endpoints"), + (name = "mv", description = "Materialized view endpoints"), ) )] pub struct V1ApiDoc; diff --git a/lib/luminork-server/src/service/v1/mv/get.rs b/lib/luminork-server/src/service/v1/mv/get.rs new file mode 100644 index 0000000000..c81cd61fa8 --- /dev/null +++ b/lib/luminork-server/src/service/v1/mv/get.rs @@ -0,0 +1,87 @@ +use axum::{ + Json, + extract::Query, +}; +use sdf_extract::FriggStore; +use serde::{ + Deserialize, + Serialize, +}; +use si_frontend_mv_types::object::FrontendObject; +use utoipa::{ + IntoParams, + ToSchema, +}; + +use super::{ + MvError, + MvResult, +}; +use crate::extract::change_set::ChangeSetDalContext; + +#[derive(Deserialize, Serialize, ToSchema, IntoParams, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GetParams { + #[schema(example = "01H9ZQD35JPMBGHH69BT0Q79VY", nullable = false, value_type = String)] + pub entity_id: String, + #[schema(example = "ComponentList", nullable = false, value_type = String)] + pub kind: String, +} + +#[derive(Deserialize, Serialize, Debug, ToSchema, Clone)] +#[serde(rename_all = "camelCase")] +pub struct MvResponse { + #[schema(example = "ComponentList")] + pub kind: String, + #[schema(example = "")] + pub id: String, + #[schema(example = "")] + pub checksum: String, + #[schema(example = "{}")] + pub data: serde_json::Value, +} + +#[utoipa::path( + get, + path = "/v1/w/{workspace_id}/change-sets/{change_set_id}/mv", + params( + ("workspace_id" = String, Path, description = "Workspace identifier"), + ("change_set_id" = String, Path, description = "Change Set identifier"), + GetParams, + ), + tag = "mv", + summary = "Identifiers for a materialized view", + responses( + (status = 200, description = "Mv retrieved successfully", body = MvResponse), + (status = 404, description = "Mv not found"), + (status = 500, description = "Internal server error", body = crate::service::v1::common::ApiError) + ) +)] +pub async fn get( + ChangeSetDalContext(ref ctx): ChangeSetDalContext, + Query(params): Query, + FriggStore(frigg): FriggStore, +) -> MvResult> { + let obj = frigg + .get_current_workspace_object( + ctx.workspace_pk()?, + ctx.change_set_id(), + ¶ms.kind, + ¶ms.entity_id, + ) + .await?; + match obj { + Some(FrontendObject { + kind, + id, + checksum, + data, + }) => Ok(Json(MvResponse { + kind, + id, + checksum, + data, + })), + None => Err(MvError::NotFound(params.kind, params.entity_id)), + } +} diff --git a/lib/luminork-server/src/service/v1/mv/mod.rs b/lib/luminork-server/src/service/v1/mv/mod.rs new file mode 100644 index 0000000000..7b9601d0ee --- /dev/null +++ b/lib/luminork-server/src/service/v1/mv/mod.rs @@ -0,0 +1,54 @@ +use axum::{ + Router, + http::StatusCode, + response::IntoResponse, + routing::get, +}; +use dal::TransactionsError; +use frigg::FriggError; +use thiserror::Error; + +use crate::AppState; + +pub mod get; + +#[remain::sorted] +#[derive(Debug, Error)] +pub enum MvError { + #[error("decode error: {0}")] + Decode(#[from] ulid::DecodeError), + #[error("frigg error: {0}")] + Frigg(#[from] FriggError), + #[error("join error: {0}")] + Join(#[from] tokio::task::JoinError), + #[error("MV not found error: {0} {1}")] + NotFound(String, String), + #[error("slow runtime error: {0}")] + SlowRuntime(#[from] dal::slow_rt::SlowRuntimeError), + #[error("transactions error: {0}")] + Transactions(#[from] TransactionsError), + #[error("workspace snapshot error: {0}")] + WorkspaceSnapshot(#[from] dal::WorkspaceSnapshotError), +} + +impl IntoResponse for MvError { + fn into_response(self) -> axum::response::Response { + use crate::service::v1::common::ErrorIntoResponse; + self.to_api_response() + } +} + +impl crate::service::v1::common::ErrorIntoResponse for MvError { + fn status_and_message(&self) -> (StatusCode, String) { + match self { + MvError::NotFound(_kind, _id) => (StatusCode::NOT_FOUND, self.to_string()), + _ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), + } + } +} + +pub fn routes() -> Router { + Router::new().route("/", get(get::get)) +} + +pub type MvResult = Result; diff --git a/lib/luminork-server/src/service/v1/workspaces.rs b/lib/luminork-server/src/service/v1/workspaces.rs index 7cd64768b8..40a6d7ff63 100644 --- a/lib/luminork-server/src/service/v1/workspaces.rs +++ b/lib/luminork-server/src/service/v1/workspaces.rs @@ -74,6 +74,7 @@ pub fn routes(state: AppState) -> Router { .nest("/actions", super::actions::routes()) .nest("/secrets", super::secrets::routes()) .nest("/management-funcs", super::management_funcs::routes()) + .nest("/mv", super::mv::routes()) .nest("/debug-funcs", super::debug_funcs::routes()) .route( "/request_approval",