From 134ba3c34c11ade9bc71f3a95173a02138ab7529 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 24 May 2024 15:08:24 -0600 Subject: [PATCH] checkpoint on integratng wstore. moved to wails data structures, got immer working again, Window object, transitioned to generic DB ops, lots more --- db/migrations-wstore/000001_init.up.sql | 5 + frontend/app/app.tsx | 10 ++ frontend/app/block/block.tsx | 1 + frontend/app/element/quickelems.tsx | 6 +- frontend/app/store/global.ts | 13 +- frontend/app/tab/tab.tsx | 2 +- frontend/app/view/preview.tsx | 3 +- frontend/app/workspace/workspace.tsx | 49 ++++-- frontend/types/custom.d.ts | 34 +--- frontend/wave.ts | 27 ++- main.go | 46 ++++- pkg/blockcontroller/blockcontroller.go | 83 ++++++--- pkg/blockstore/dbsetup.go | 64 +------ pkg/service/blockservice/blockservice.go | 23 ++- pkg/service/clientservice/clientservice.go | 56 ++++++ pkg/util/migrateutil/migrateutil.go | 67 ++++++++ pkg/wstore/wstore.go | 110 +++++++++--- pkg/wstore/wstore_dbops.go | 191 ++++++++++++++------- pkg/wstore/wstore_dbsetup.go | 41 +---- 19 files changed, 542 insertions(+), 289 deletions(-) create mode 100644 pkg/service/clientservice/clientservice.go create mode 100644 pkg/util/migrateutil/migrateutil.go diff --git a/db/migrations-wstore/000001_init.up.sql b/db/migrations-wstore/000001_init.up.sql index b8a3e9275..957bc803d 100644 --- a/db/migrations-wstore/000001_init.up.sql +++ b/db/migrations-wstore/000001_init.up.sql @@ -3,6 +3,11 @@ CREATE TABLE db_client ( data json NOT NULL ); +CREATE TABLE db_window ( + windowid varchar(36) PRIMARY KEY, + data json NOT NULL +); + CREATE TABLE db_workspace ( workspaceid varchar(36) PRIMARY KEY, data json NOT NULL diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index ab41992da..eb8d5a8a8 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -19,6 +19,16 @@ const App = () => { }; const AppInner = () => { + const client = jotai.useAtomValue(atoms.clientAtom); + const windowData = jotai.useAtomValue(atoms.windowData); + if (client == null || windowData == null) { + return ( +
+
invalid configuration, client or window was not loaded
+
+ ); + } + return (
diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index fe7af24b2..b0e618408 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -31,6 +31,7 @@ const Block = ({ tabId, blockId }: { tabId: string; blockId: string }) => { setDims({ width: newWidth, height: newHeight }); } }, [blockRef.current]); + let blockElem: JSX.Element = null; const blockAtom = blockDataMap.get(blockId); const blockData = jotai.useAtomValue(blockAtom); diff --git a/frontend/app/element/quickelems.tsx b/frontend/app/element/quickelems.tsx index 5de52a253..2777acfed 100644 --- a/frontend/app/element/quickelems.tsx +++ b/frontend/app/element/quickelems.tsx @@ -3,6 +3,10 @@ import "./quickelems.less"; +function CenteredLoadingDiv() { + return loading...; +} + function CenteredDiv({ children }: { children: React.ReactNode }) { return (
@@ -11,4 +15,4 @@ function CenteredDiv({ children }: { children: React.ReactNode }) { ); } -export { CenteredDiv as CenteredDiv }; +export { CenteredDiv, CenteredLoadingDiv }; diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 3217eedf7..f615f4b95 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -15,14 +15,19 @@ const globalStore = jotai.createStore(); const tabId1 = uuidv4(); -const tabArr: TabData[] = [{ name: "Tab 1", tabid: tabId1, blockIds: [] }]; +const tabArr: wstore.Tab[] = [new wstore.Tab({ name: "Tab 1", tabid: tabId1, blockids: [] })]; const blockDataMap = new Map>(); const blockAtomCache = new Map>>(); const atoms = { activeTabId: jotai.atom(tabId1), - tabsAtom: jotai.atom(tabArr), + tabsAtom: jotai.atom(tabArr), blockDataMap: blockDataMap, + clientAtom: jotai.atom(null) as jotai.PrimitiveAtom, + + // initialized in wave.ts (will not be null inside of application) + windowId: jotai.atom(null) as jotai.PrimitiveAtom, + windowData: jotai.atom(null) as jotai.PrimitiveAtom, }; type SubjectWithRef = rxjs.Subject & { refCount: number; release: () => void }; @@ -65,7 +70,7 @@ function addBlockIdToTab(tabId: string, blockId: string) { let tabArr = globalStore.get(atoms.tabsAtom); const newTabArr = produce(tabArr, (draft) => { const tab = draft.find((tab) => tab.tabid == tabId); - tab.blockIds.push(blockId); + tab.blockids.push(blockId); }); globalStore.set(atoms.tabsAtom, newTabArr); } @@ -93,7 +98,7 @@ function removeBlockFromTab(tabId: string, blockId: string) { let tabArr = globalStore.get(atoms.tabsAtom); const newTabArr = produce(tabArr, (draft) => { const tab = draft.find((tab) => tab.tabid == tabId); - tab.blockIds = tab.blockIds.filter((id) => id !== blockId); + tab.blockids = tab.blockids.filter((id) => id !== blockId); }); globalStore.set(atoms.tabsAtom, newTabArr); removeBlock(blockId); diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 6b762936a..1944ba47c 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -16,7 +16,7 @@ const TabContent = ({ tabId }: { tabId: string }) => { } return (
- {tabData.blockIds.map((blockId: string) => { + {tabData.blockids.map((blockId: string) => { return (
diff --git a/frontend/app/view/preview.tsx b/frontend/app/view/preview.tsx index 3390cd896..e3ecaad88 100644 --- a/frontend/app/view/preview.tsx +++ b/frontend/app/view/preview.tsx @@ -9,6 +9,7 @@ import { FileService, FileInfo, FullFile } from "@/bindings/fileservice"; import * as util from "@/util/util"; import { CenteredDiv } from "../element/quickelems"; import { DirectoryTable } from "@/element/directorytable"; +import * as wstore from "@/gopkg/wstore"; import "./view.less"; @@ -61,7 +62,7 @@ function DirectoryPreview({ contentAtom }: { contentAtom: jotai.Atom = blockDataMap.get(blockId); + const blockDataAtom: jotai.Atom = blockDataMap.get(blockId); const fileNameAtom = useBlockAtom(blockId, "preview:filename", () => jotai.atom((get) => { return get(blockDataAtom)?.meta?.file; diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx index afe029c6d..0e296e5e5 100644 --- a/frontend/app/workspace/workspace.tsx +++ b/frontend/app/workspace/workspace.tsx @@ -8,11 +8,15 @@ import { clsx } from "clsx"; import { atoms, addBlockIdToTab, blockDataMap } from "@/store/global"; import { v4 as uuidv4 } from "uuid"; import { BlockService } from "@/bindings/blockservice"; +import { ClientService } from "@/bindings/clientservice"; +import { Workspace } from "@/gopkg/wstore"; import * as wstore from "@/gopkg/wstore"; +import * as jotaiUtil from "jotai/utils"; import "./workspace.less"; +import { CenteredLoadingDiv, CenteredDiv } from "../element/quickelems"; -function Tab({ tab }: { tab: TabData }) { +function Tab({ tab }: { tab: wstore.Tab }) { const [activeTab, setActiveTab] = jotai.useAtom(atoms.activeTabId); return (
setActiveTab(tab.tabid)}> @@ -25,11 +29,12 @@ function TabBar() { const [tabData, setTabData] = jotai.useAtom(atoms.tabsAtom); const [activeTab, setActiveTab] = jotai.useAtom(atoms.activeTabId); const tabs = jotai.useAtomValue(atoms.tabsAtom); + const client = jotai.useAtomValue(atoms.clientAtom); function handleAddTab() { const newTabId = uuidv4(); const newTabName = "Tab " + (tabData.length + 1); - setTabData([...tabData, { name: newTabName, tabid: newTabId, blockIds: [] }]); + setTabData([...tabData, { name: newTabName, tabid: newTabId, blockids: [] }]); setActiveTab(newTabId); } @@ -48,8 +53,8 @@ function TabBar() { function Widgets() { const activeTabId = jotai.useAtomValue(atoms.activeTabId); - async function createBlock(blockDef: BlockDef) { - const rtOpts = { termsize: { rows: 25, cols: 80 } }; + async function createBlock(blockDef: wstore.BlockDef) { + const rtOpts: wstore.RuntimeOpts = new wstore.RuntimeOpts({ termsize: { rows: 25, cols: 80 } }); const rtnBlock: wstore.Block = await BlockService.CreateBlock(blockDef, rtOpts); const newBlockAtom = jotai.atom(rtnBlock); blockDataMap.set(rtnBlock.blockid, newBlockAtom); @@ -57,25 +62,25 @@ function Widgets() { } async function clickTerminal() { - const termBlockDef = { + const termBlockDef = new wstore.BlockDef({ controller: "shell", view: "term", - }; + }); createBlock(termBlockDef); } async function clickPreview(fileName: string) { - const markdownDef = { + const markdownDef = new wstore.BlockDef({ view: "preview", meta: { file: fileName }, - }; + }); createBlock(markdownDef); } async function clickPlot() { - const plotDef = { + const plotDef = new wstore.BlockDef({ view: "plot", - }; + }); createBlock(plotDef); } @@ -106,17 +111,35 @@ function Widgets() { ); } -function Workspace() { +function WorkspaceElem() { + const windowData = jotai.useAtomValue(atoms.windowData); const activeTabId = jotai.useAtomValue(atoms.activeTabId); + const workspaceId = windowData.workspaceid; + const wsAtom = React.useMemo(() => { + return jotaiUtil.loadable( + jotai.atom(async (get) => { + const ws = await ClientService.GetWorkspace(workspaceId); + return ws; + }) + ); + }, [workspaceId]); + const wsLoadable = jotai.useAtomValue(wsAtom); + if (wsLoadable.state === "loading") { + return ; + } + if (wsLoadable.state === "hasError") { + return Error: {wsLoadable.error?.toString()}; + } + const ws: Workspace = wsLoadable.data; return (
- +
); } -export { Workspace }; +export { WorkspaceElem as Workspace }; diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index f3f95c4c4..d12626e2e 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -1,38 +1,6 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -declare global { - type MetaDataType = Record; - - type TabData = { - name: string; - tabid: string; - blockIds: string[]; - }; - - type BlockData = { - blockid: string; - blockdef: BlockDef; - controller: string; - controllerstatus: string; - view: string; - meta?: MetaDataType; - }; - - type FileDef = { - filetype?: string; - path?: string; - url?: string; - content?: string; - meta?: MetaDataType; - }; - - type BlockDef = { - controller?: string; - view: string; - files?: FileDef[]; - meta?: MetaDataType; - }; -} +declare global {} export {}; diff --git a/frontend/wave.ts b/frontend/wave.ts index 06c44d839..992eed3c7 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -5,10 +5,35 @@ import * as React from "react"; import { createRoot } from "react-dom/client"; import { App } from "./app/app"; import { loadFonts } from "./util/fontutil"; +import { ClientService } from "@/bindings/clientservice"; +import { Client } from "@/gopkg/wstore"; +import { globalStore, atoms } from "@/store/global"; +import * as wailsRuntime from "@wailsio/runtime"; +import * as wstore from "@/gopkg/wstore"; +import { immerable } from "immer"; + +const urlParams = new URLSearchParams(window.location.search); +const windowId = urlParams.get("windowid"); +globalStore.set(atoms.windowId, windowId); + +wstore.Block.prototype[immerable] = true; +wstore.Tab.prototype[immerable] = true; +wstore.Client.prototype[immerable] = true; +wstore.Window.prototype[immerable] = true; +wstore.Workspace.prototype[immerable] = true; +wstore.BlockDef.prototype[immerable] = true; +wstore.RuntimeOpts.prototype[immerable] = true; +wstore.FileDef.prototype[immerable] = true; +wstore.Point.prototype[immerable] = true; +wstore.WinSize.prototype[immerable] = true; loadFonts(); -document.addEventListener("DOMContentLoaded", () => { +document.addEventListener("DOMContentLoaded", async () => { + const client = await ClientService.GetClientData(); + globalStore.set(atoms.clientAtom, client); + const window = await ClientService.GetWindow(windowId); + globalStore.set(atoms.windowData, window); let reactElem = React.createElement(App, null, null); let elem = document.getElementById("main"); let root = createRoot(elem); diff --git a/main.go b/main.go index 6995a7027..22e10f59b 100644 --- a/main.go +++ b/main.go @@ -6,15 +6,18 @@ package main // Note, main.go needs to be in the root of the project for the go:embed directive to work. import ( + "context" "embed" "log" "net/http" "runtime" "strings" + "time" "github.com/wavetermdev/thenextwave/pkg/blockstore" "github.com/wavetermdev/thenextwave/pkg/eventbus" "github.com/wavetermdev/thenextwave/pkg/service/blockservice" + "github.com/wavetermdev/thenextwave/pkg/service/clientservice" "github.com/wavetermdev/thenextwave/pkg/service/fileservice" "github.com/wavetermdev/thenextwave/pkg/wavebase" "github.com/wavetermdev/thenextwave/pkg/wstore" @@ -33,10 +36,10 @@ func createAppMenu(app *application.App) *application.Menu { menu := application.NewMenu() menu.AddRole(application.AppMenu) fileMenu := menu.AddSubmenu("File") - newWindow := fileMenu.Add("New Window") - newWindow.OnClick(func(appContext *application.Context) { - createWindow(app) - }) + // newWindow := fileMenu.Add("New Window") + // newWindow.OnClick(func(appContext *application.Context) { + // createWindow(app) + // }) closeWindow := fileMenu.Add("Close Window") closeWindow.OnClick(func(appContext *application.Context) { app.CurrentWindow().Close() @@ -48,7 +51,7 @@ func createAppMenu(app *application.App) *application.Menu { return menu } -func createWindow(app *application.App) { +func createWindow(windowData *wstore.Window, app *application.App) { window := app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{ Title: "Wave Terminal", Mac: application.MacWindow{ @@ -56,13 +59,18 @@ func createWindow(app *application.App) { Backdrop: application.MacBackdropTranslucent, TitleBar: application.MacTitleBarHiddenInset, }, - BackgroundColour: application.NewRGB(27, 38, 54), - URL: "/public/index.html", + BackgroundColour: application.NewRGB(0, 0, 0), + URL: "/public/index.html?windowid=" + windowData.WindowId, + X: windowData.Pos.X, + Y: windowData.Pos.Y, + Width: windowData.WinSize.Width, + Height: windowData.WinSize.Height, }) eventbus.RegisterWailsWindow(window) window.On(events.Common.WindowClosing, func(event *application.WindowEvent) { eventbus.UnregisterWailsWindow(window.ID()) }) + window.Show() } type waveAssetHandler struct { @@ -110,6 +118,11 @@ func main() { log.Printf("error initializing wstore: %v\n", err) return } + err = wstore.EnsureInitialData() + if err != nil { + log.Printf("error ensuring initial data: %v\n", err) + return + } app := application.New(application.Options{ Name: "NextWave", @@ -117,6 +130,7 @@ func main() { Services: []application.Service{ application.NewService(&fileservice.FileService{}), application.NewService(&blockservice.BlockService{}), + application.NewService(&clientservice.ClientService{}), }, Icon: appIcon, Assets: application.AssetOptions{ @@ -130,7 +144,23 @@ func main() { app.SetMenu(menu) eventbus.RegisterWailsApp(app) - createWindow(app) + setupCtx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelFn() + client, err := wstore.DBGetSingleton[wstore.Client](setupCtx) + if err != nil { + log.Printf("error getting client data: %v\n", err) + return + } + mainWindow, err := wstore.DBGet[wstore.Window](setupCtx, client.MainWindowId) + if err != nil { + log.Printf("error getting main window: %v\n", err) + return + } + if mainWindow == nil { + log.Printf("no main window data\n") + return + } + createWindow(mainWindow, app) eventbus.Start() defer eventbus.Shutdown() diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index 53ccded96..182e07ef8 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -4,12 +4,14 @@ package blockcontroller import ( + "context" "encoding/base64" "encoding/json" "fmt" "io" "log" "sync" + "time" "github.com/creack/pty" "github.com/google/uuid" @@ -24,6 +26,8 @@ const ( BlockController_Cmd = "cmd" ) +const DefaultTimeout = 2 * time.Second + var globalLock = &sync.Mutex{} var blockControllerMap = make(map[string]*BlockController) @@ -32,11 +36,18 @@ type BlockController struct { BlockId string BlockDef *wstore.BlockDef InputCh chan BlockCommand + Status string ShellProc *shellexec.ShellProc ShellInputCh chan *InputCommand } +func (bc *BlockController) WithLock(f func()) { + bc.Lock.Lock() + defer bc.Lock.Unlock() + f() +} + func jsonDeepCopy(val map[string]any) (map[string]any, error) { barr, err := json.Marshal(val) if err != nil { @@ -50,10 +61,9 @@ func jsonDeepCopy(val map[string]any) (map[string]any, error) { return rtn, nil } -func CreateBlock(bdef *wstore.BlockDef, rtOpts *wstore.RuntimeOpts) (*wstore.Block, error) { +func CreateBlock(ctx context.Context, bdef *wstore.BlockDef, rtOpts *wstore.RuntimeOpts) (*wstore.Block, error) { blockId := uuid.New().String() blockData := &wstore.Block{ - Lock: &sync.Mutex{}, BlockId: blockId, BlockDef: bdef, Controller: bdef.Controller, @@ -65,7 +75,10 @@ func CreateBlock(bdef *wstore.BlockDef, rtOpts *wstore.RuntimeOpts) (*wstore.Blo if err != nil { return nil, fmt.Errorf("error copying meta: %w", err) } - wstore.BlockMap.Set(blockId, blockData) + err = wstore.DBInsert(ctx, blockData) + if err != nil { + return nil, fmt.Errorf("error inserting block: %w", err) + } if blockData.Controller != "" { StartBlockController(blockId, blockData) } @@ -179,10 +192,10 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts) error { func (bc *BlockController) Run(bdata *wstore.Block) { defer func() { - bdata.WithLock(func() { + bc.WithLock(func() { // if the controller had an error status, don't change it - if bdata.ControllerStatus == "running" { - bdata.ControllerStatus = "done" + if bc.Status == "running" { + bc.Status = "done" } }) eventbus.SendEvent(application.WailsEvent{ @@ -193,8 +206,8 @@ func (bc *BlockController) Run(bdata *wstore.Block) { defer globalLock.Unlock() delete(blockControllerMap, bc.BlockId) }() - bdata.WithLock(func() { - bdata.ControllerStatus = "running" + bc.WithLock(func() { + bc.Status = "running" }) // only controller is "shell" for now @@ -221,9 +234,6 @@ func (bc *BlockController) Run(bdata *wstore.Block) { func StartBlockController(blockId string, bdata *wstore.Block) { if bdata.Controller != BlockController_Shell { log.Printf("unknown controller %q\n", bdata.Controller) - bdata.WithLock(func() { - bdata.ControllerStatus = "error" - }) return } globalLock.Lock() @@ -234,6 +244,7 @@ func StartBlockController(blockId string, bdata *wstore.Block) { bc := &BlockController{ Lock: &sync.Mutex{}, BlockId: blockId, + Status: "init", InputCh: make(chan BlockCommand), } blockControllerMap[blockId] = bc @@ -246,31 +257,47 @@ func GetBlockController(blockId string) *BlockController { return blockControllerMap[blockId] } -func ProcessStaticCommand(blockId string, cmdGen BlockCommand) { +func ProcessStaticCommand(blockId string, cmdGen BlockCommand) error { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() switch cmd := cmdGen.(type) { case *MessageCommand: log.Printf("MESSAGE: %s | %q\n", blockId, cmd.Message) + return nil case *SetViewCommand: log.Printf("SETVIEW: %s | %q\n", blockId, cmd.View) - block := wstore.BlockMap.Get(blockId) - if block != nil { - block.WithLock(func() { - block.View = cmd.View - }) + block, err := wstore.DBGet[wstore.Block](ctx, blockId) + if err != nil { + return fmt.Errorf("error getting block: %w", err) } + block.View = cmd.View + err = wstore.DBUpdate[wstore.Block](ctx, block) + if err != nil { + return fmt.Errorf("error updating block: %w", err) + } + return nil case *SetMetaCommand: log.Printf("SETMETA: %s | %v\n", blockId, cmd.Meta) - block := wstore.BlockMap.Get(blockId) - if block != nil { - block.WithLock(func() { - for k, v := range cmd.Meta { - if v == nil { - delete(block.Meta, k) - continue - } - block.Meta[k] = v - } - }) + block, err := wstore.DBGet[wstore.Block](ctx, blockId) + if err != nil { + return fmt.Errorf("error getting block: %w", err) } + if block == nil { + return nil + } + for k, v := range cmd.Meta { + if v == nil { + delete(block.Meta, k) + continue + } + block.Meta[k] = v + } + err = wstore.DBUpdate(ctx, block) + if err != nil { + return fmt.Errorf("error updating block: %w", err) + } + return nil + default: + return fmt.Errorf("unknown command type %T", cmdGen) } } diff --git a/pkg/blockstore/dbsetup.go b/pkg/blockstore/dbsetup.go index c6e879742..a4937ab97 100644 --- a/pkg/blockstore/dbsetup.go +++ b/pkg/blockstore/dbsetup.go @@ -13,11 +13,9 @@ import ( "path" "time" + "github.com/wavetermdev/thenextwave/pkg/util/migrateutil" "github.com/wavetermdev/thenextwave/pkg/wavebase" - "github.com/golang-migrate/migrate/v4" - sqlite3migrate "github.com/golang-migrate/migrate/v4/database/sqlite3" - "github.com/golang-migrate/migrate/v4/source/iofs" "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" "github.com/sawka/txwrap" @@ -40,7 +38,7 @@ func InitBlockstore() error { if err != nil { return err } - err = MigrateBlockstore() + err = migrateutil.Migrate("blockstore", globalDB.DB, dbfs.BlockstoreMigrationFS, "migrations-blockstore") if err != nil { return err } @@ -79,61 +77,3 @@ func WithTx(ctx context.Context, fn func(tx *TxWrap) error) error { func WithTxRtn[RT any](ctx context.Context, fn func(tx *TxWrap) (RT, error)) (RT, error) { return txwrap.WithTxRtn(ctx, globalDB, fn) } - -func MakeBlockstoreMigrate() (*migrate.Migrate, error) { - fsVar, err := iofs.New(dbfs.BlockstoreMigrationFS, "migrations-blockstore") - if err != nil { - return nil, fmt.Errorf("opening iofs: %w", err) - } - mdriver, err := sqlite3migrate.WithInstance(globalDB.DB, &sqlite3migrate.Config{}) - if err != nil { - return nil, fmt.Errorf("making blockstore migration driver: %w", err) - } - m, err := migrate.NewWithInstance("iofs", fsVar, "sqlite3", mdriver) - if err != nil { - return nil, fmt.Errorf("making blockstore migration db[%s]: %w", GetDBName(), err) - } - return m, nil -} - -func MigrateBlockstore() error { - log.Printf("migrate blockstore\n") - m, err := MakeBlockstoreMigrate() - if err != nil { - return err - } - curVersion, dirty, err := GetMigrateVersion(m) - if dirty { - return fmt.Errorf("cannot migrate up, database is dirty") - } - if err != nil { - return fmt.Errorf("cannot get current migration version: %v", err) - } - err = m.Up() - if err != nil && err != migrate.ErrNoChange { - return fmt.Errorf("migrating blockstore: %w", err) - } - newVersion, _, err := GetMigrateVersion(m) - if err != nil { - return fmt.Errorf("cannot get new migration version: %v", err) - } - if newVersion != curVersion { - log.Printf("[db] blockstore migration done, version %d -> %d\n", curVersion, newVersion) - } - return nil -} - -func GetMigrateVersion(m *migrate.Migrate) (uint, bool, error) { - if m == nil { - var err error - m, err = MakeBlockstoreMigrate() - if err != nil { - return 0, false, err - } - } - curVersion, dirty, err := m.Version() - if err == migrate.ErrNilVersion { - return 0, false, nil - } - return curVersion, dirty, err -} diff --git a/pkg/service/blockservice/blockservice.go b/pkg/service/blockservice/blockservice.go index dfd3ef2e0..20e08fc54 100644 --- a/pkg/service/blockservice/blockservice.go +++ b/pkg/service/blockservice/blockservice.go @@ -10,24 +10,23 @@ import ( "time" "github.com/wavetermdev/thenextwave/pkg/blockcontroller" - "github.com/wavetermdev/thenextwave/pkg/util/utilfn" "github.com/wavetermdev/thenextwave/pkg/wstore" ) type BlockService struct{} -func (bs *BlockService) CreateBlock(bdefMap map[string]any, rtOptsMap map[string]any) (*wstore.Block, error) { - var bdef wstore.BlockDef - err := utilfn.JsonMapToStruct(bdefMap, &bdef) - if err != nil { - return nil, fmt.Errorf("error unmarshalling BlockDef: %w", err) +const DefaultTimeout = 2 * time.Second + +func (bs *BlockService) CreateBlock(bdef *wstore.BlockDef, rtOpts *wstore.RuntimeOpts) (*wstore.Block, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelFn() + if bdef == nil { + return nil, fmt.Errorf("block definition is nil") } - var rtOpts wstore.RuntimeOpts - err = utilfn.JsonMapToStruct(rtOptsMap, &rtOpts) - if err != nil { - return nil, fmt.Errorf("error unmarshalling RuntimeOpts: %w", err) + if rtOpts == nil { + return nil, fmt.Errorf("runtime options is nil") } - blockData, err := blockcontroller.CreateBlock(&bdef, &rtOpts) + blockData, err := blockcontroller.CreateBlock(ctx, bdef, rtOpts) if err != nil { return nil, fmt.Errorf("error creating block: %w", err) } @@ -41,7 +40,7 @@ func (bs *BlockService) CloseBlock(blockId string) { func (bs *BlockService) GetBlockData(blockId string) (*wstore.Block, error) { ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() - blockData, err := wstore.BlockGet(ctx, blockId) + blockData, err := wstore.DBGet[wstore.Block](ctx, blockId) if err != nil { return nil, fmt.Errorf("error getting block data: %w", err) } diff --git a/pkg/service/clientservice/clientservice.go b/pkg/service/clientservice/clientservice.go new file mode 100644 index 000000000..e86c5fe47 --- /dev/null +++ b/pkg/service/clientservice/clientservice.go @@ -0,0 +1,56 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package clientservice + +import ( + "context" + "fmt" + "time" + + "github.com/wavetermdev/thenextwave/pkg/wstore" +) + +type ClientService struct{} + +const DefaultTimeout = 2 * time.Second + +func (cs *ClientService) GetClientData() (*wstore.Client, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + clientData, err := wstore.DBGetSingleton[wstore.Client](ctx) + if err != nil { + return nil, fmt.Errorf("error getting client data: %w", err) + } + return clientData, nil +} + +func (cs *ClientService) GetWorkspace(workspaceId string) (*wstore.Workspace, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + ws, err := wstore.DBGet[wstore.Workspace](ctx, workspaceId) + if err != nil { + return nil, fmt.Errorf("error getting workspace: %w", err) + } + return ws, nil +} + +func (cs *ClientService) GetTab(tabId string) (*wstore.Tab, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + tab, err := wstore.DBGet[wstore.Tab](ctx, tabId) + if err != nil { + return nil, fmt.Errorf("error getting tab: %w", err) + } + return tab, nil +} + +func (cs *ClientService) GetWindow(windowId string) (*wstore.Window, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + window, err := wstore.DBGet[wstore.Window](ctx, windowId) + if err != nil { + return nil, fmt.Errorf("error getting window: %w", err) + } + return window, nil +} diff --git a/pkg/util/migrateutil/migrateutil.go b/pkg/util/migrateutil/migrateutil.go new file mode 100644 index 000000000..c27f5a329 --- /dev/null +++ b/pkg/util/migrateutil/migrateutil.go @@ -0,0 +1,67 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package migrateutil + +import ( + "database/sql" + "fmt" + "io/fs" + "log" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/source/iofs" + + sqlite3migrate "github.com/golang-migrate/migrate/v4/database/sqlite3" +) + +func GetMigrateVersion(m *migrate.Migrate) (uint, bool, error) { + curVersion, dirty, err := m.Version() + if err == migrate.ErrNilVersion { + return 0, false, nil + } + return curVersion, dirty, err +} + +func MakeMigrate(storeName string, db *sql.DB, migrationFS fs.FS, migrationsName string) (*migrate.Migrate, error) { + fsVar, err := iofs.New(migrationFS, migrationsName) + if err != nil { + return nil, fmt.Errorf("opening fs: %w", err) + } + mdriver, err := sqlite3migrate.WithInstance(db, &sqlite3migrate.Config{}) + if err != nil { + return nil, fmt.Errorf("making %s migration driver: %w", storeName, err) + } + m, err := migrate.NewWithInstance("iofs", fsVar, "sqlite3", mdriver) + if err != nil { + return nil, fmt.Errorf("making %s migration: %w", storeName, err) + } + return m, nil +} + +func Migrate(storeName string, db *sql.DB, migrationFS fs.FS, migrationsName string) error { + log.Printf("migrate %s\n", storeName) + m, err := MakeMigrate(storeName, db, migrationFS, migrationsName) + if err != nil { + return err + } + curVersion, dirty, err := GetMigrateVersion(m) + if dirty { + return fmt.Errorf("%s, migrate up, database is dirty", storeName) + } + if err != nil { + return fmt.Errorf("%s, cannot get current migration version: %v", storeName, err) + } + err = m.Up() + if err != nil && err != migrate.ErrNoChange { + return fmt.Errorf("migrating %s: %w", storeName, err) + } + newVersion, _, err := GetMigrateVersion(m) + if err != nil { + return fmt.Errorf("%s, cannot get new migration version: %v", storeName, err) + } + if newVersion != curVersion { + log.Printf("[db] %s migration done, version %d -> %d\n", storeName, curVersion, newVersion) + } + return nil +} diff --git a/pkg/wstore/wstore.go b/pkg/wstore/wstore.go index 62265770d..9fb6cd1de 100644 --- a/pkg/wstore/wstore.go +++ b/pkg/wstore/wstore.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "sync" + "time" "github.com/google/uuid" "github.com/wavetermdev/thenextwave/pkg/shellexec" @@ -18,7 +19,28 @@ var TabMap = ds.NewSyncMap[*Tab]() var BlockMap = ds.NewSyncMap[*Block]() type Client struct { - DefaultWorkspaceId string `json:"defaultworkspaceid"` + ClientId string `json:"clientid"` + MainWindowId string `json:"mainwindowid"` +} + +func (c Client) GetId() string { + return c.ClientId +} + +// stores the ui-context of the window +// workspaceid, active tab, active block within each tab, window size, etc. +type Window struct { + WindowId string `json:"windowid"` + WorkspaceId string `json:"workspaceid"` + ActiveTabId string `json:"activetabid"` + ActiveBlockMap map[string]string `json:"activeblockmap"` // map from tabid to blockid + Pos Point `json:"pos"` + WinSize WinSize `json:"winsize"` + LastFocusTs int64 `json:"lastfocusts"` +} + +func (w Window) GetId() string { + return w.WindowId } type Workspace struct { @@ -28,6 +50,10 @@ type Workspace struct { TabIds []string `json:"tabids"` } +func (ws Workspace) GetId() string { + return ws.WorkspaceId +} + func (ws *Workspace) WithLock(f func()) { ws.Lock.Lock() defer ws.Lock.Unlock() @@ -41,6 +67,10 @@ type Tab struct { BlockIds []string `json:"blockids"` } +func (tab Tab) GetId() string { + return tab.TabId +} + func (tab *Tab) WithLock(f func()) { tab.Lock.Lock() defer tab.Lock.Unlock() @@ -67,25 +97,31 @@ type RuntimeOpts struct { WinSize WinSize `json:"winsize,omitempty"` } +type Point struct { + X int `json:"x"` + Y int `json:"y"` +} + type WinSize struct { Width int `json:"width"` Height int `json:"height"` } type Block struct { - Lock *sync.Mutex `json:"-"` - BlockId string `json:"blockid"` - BlockDef *BlockDef `json:"blockdef"` - Controller string `json:"controller"` - ControllerStatus string `json:"controllerstatus"` - View string `json:"view"` - Meta map[string]any `json:"meta,omitempty"` - RuntimeOpts *RuntimeOpts `json:"runtimeopts,omitempty"` + BlockId string `json:"blockid"` + BlockDef *BlockDef `json:"blockdef"` + Controller string `json:"controller"` + View string `json:"view"` + Meta map[string]any `json:"meta,omitempty"` + RuntimeOpts *RuntimeOpts `json:"runtimeopts,omitempty"` } +func (b Block) GetId() string { + return b.BlockId +} + +// TODO remove func (b *Block) WithLock(f func()) { - b.Lock.Lock() - defer b.Lock.Unlock() f() } @@ -121,30 +157,60 @@ func CreateWorkspace() (*Workspace, error) { return ws, nil } -func EnsureWorkspace(ctx context.Context) error { - wsCount, err := WorkspaceCount(ctx) +func EnsureInitialData() error { + ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelFn() + clientCount, err := DBGetCount[Client](ctx) if err != nil { - return fmt.Errorf("error getting workspace count: %w", err) + return fmt.Errorf("error getting client count: %w", err) } - if wsCount > 0 { + if clientCount > 0 { return nil } - ws := &Workspace{ - Lock: &sync.Mutex{}, - WorkspaceId: uuid.New().String(), - Name: "default", + windowId := uuid.New().String() + workspaceId := uuid.New().String() + tabId := uuid.New().String() + client := &Client{ + ClientId: uuid.New().String(), + MainWindowId: windowId, } - err = WorkspaceInsert(ctx, ws) + err = DBInsert(ctx, client) + if err != nil { + return fmt.Errorf("error inserting client: %w", err) + } + window := &Window{ + WindowId: windowId, + WorkspaceId: workspaceId, + ActiveTabId: tabId, + ActiveBlockMap: make(map[string]string), + Pos: Point{ + X: 100, + Y: 100, + }, + WinSize: WinSize{ + Width: 800, + Height: 600, + }, + } + err = DBInsert(ctx, window) + if err != nil { + return fmt.Errorf("error inserting window: %w", err) + } + ws := &Workspace{ + WorkspaceId: workspaceId, + Name: "default", + TabIds: []string{tabId}, + } + err = DBInsert(ctx, ws) if err != nil { return fmt.Errorf("error inserting workspace: %w", err) } tab := &Tab{ - Lock: &sync.Mutex{}, TabId: uuid.New().String(), Name: "Tab 1", BlockIds: []string{}, } - err = TabInsert(ctx, tab, ws.WorkspaceId) + err = DBInsert(ctx, tab) if err != nil { return fmt.Errorf("error inserting tab: %w", err) } diff --git a/pkg/wstore/wstore_dbops.go b/pkg/wstore/wstore_dbops.go index 3840c8935..003e319a5 100644 --- a/pkg/wstore/wstore_dbops.go +++ b/pkg/wstore/wstore_dbops.go @@ -6,92 +6,155 @@ package wstore import ( "context" "fmt" - - "github.com/google/uuid" + "reflect" ) -func WorkspaceCount(ctx context.Context) (int, error) { +const Table_Client = "db_client" +const Table_Workspace = "db_workspace" +const Table_Tab = "db_tab" +const Table_Block = "db_block" +const Table_Window = "db_window" + +// can replace with struct tags in the future +type ObjectWithId interface { + GetId() string +} + +// can replace these with struct tags in the future +var idColumnName = map[string]string{ + Table_Client: "clientid", + Table_Workspace: "workspaceid", + Table_Tab: "tabid", + Table_Block: "blockid", + Table_Window: "windowid", +} + +var tableToType = map[string]reflect.Type{ + Table_Client: reflect.TypeOf(Client{}), + Table_Workspace: reflect.TypeOf(Workspace{}), + Table_Tab: reflect.TypeOf(Tab{}), + Table_Block: reflect.TypeOf(Block{}), + Table_Window: reflect.TypeOf(Window{}), +} + +var typeToTable map[reflect.Type]string + +func init() { + typeToTable = make(map[reflect.Type]string) + for k, v := range tableToType { + typeToTable[v] = k + } +} + +func DBGetCount[T ObjectWithId](ctx context.Context) (int, error) { return WithTxRtn(ctx, func(tx *TxWrap) (int, error) { - query := "SELECT count(*) FROM workspace" + var valInstance T + table := typeToTable[reflect.TypeOf(valInstance)] + if table == "" { + return 0, fmt.Errorf("unknown table type: %T", valInstance) + } + query := fmt.Sprintf("SELECT count(*) FROM %s", table) return tx.GetInt(query), nil }) } -func WorkspaceInsert(ctx context.Context, ws *Workspace) error { - if ws.WorkspaceId == "" { - ws.WorkspaceId = uuid.New().String() - } - return WithTx(ctx, func(tx *TxWrap) error { - query := "INSERT INTO workspace (workspaceid, data) VALUES (?, ?)" - tx.Exec(query, ws.WorkspaceId, TxJson(tx, ws)) - return nil +func DBGetSingleton[T ObjectWithId](ctx context.Context) (*T, error) { + return WithTxRtn(ctx, func(tx *TxWrap) (*T, error) { + var rtn T + query := fmt.Sprintf("SELECT data FROM %s LIMIT 1", typeToTable[reflect.TypeOf(rtn)]) + jsonData := tx.GetString(query) + return TxReadJson[T](tx, jsonData), nil }) } -func WorkspaceGet(ctx context.Context, workspaceId string) (*Workspace, error) { - return WithTxRtn(ctx, func(tx *TxWrap) (*Workspace, error) { - query := "SELECT data FROM workspace WHERE workspaceid = ?" - jsonData := tx.GetString(query, workspaceId) - return TxReadJson[Workspace](tx, jsonData), nil - }) -} - -func WorkspaceUpdate(ctx context.Context, ws *Workspace) error { - return WithTx(ctx, func(tx *TxWrap) error { - query := "UPDATE workspace SET data = ? WHERE workspaceid = ?" - tx.Exec(query, TxJson(tx, ws), ws.WorkspaceId) - return nil - }) -} - -func addTabToWorkspace(ctx context.Context, workspaceId string, tabId string) error { - return WithTx(ctx, func(tx *TxWrap) error { - ws, err := WorkspaceGet(tx.Context(), workspaceId) - if err != nil { - return err +func DBGet[T ObjectWithId](ctx context.Context, id string) (*T, error) { + return WithTxRtn(ctx, func(tx *TxWrap) (*T, error) { + var rtn T + table := typeToTable[reflect.TypeOf(rtn)] + if table == "" { + return nil, fmt.Errorf("unknown table type: %T", rtn) } - if ws == nil { - return fmt.Errorf("workspace not found: %s", workspaceId) + query := fmt.Sprintf("SELECT data FROM %s WHERE %s = ?", table, idColumnName[table]) + jsonData := tx.GetString(query, id) + return TxReadJson[T](tx, jsonData), nil + }) +} + +type idDataType struct { + Id string + Data string +} + +func DBSelectMap[T ObjectWithId](ctx context.Context, ids []string) (map[string]*T, error) { + return WithTxRtn(ctx, func(tx *TxWrap) (map[string]*T, error) { + var valInstance T + table := typeToTable[reflect.TypeOf(valInstance)] + if table == "" { + return nil, fmt.Errorf("unknown table type: %T", &valInstance) } - ws.TabIds = append(ws.TabIds, tabId) - return WorkspaceUpdate(tx.Context(), ws) + var rows []idDataType + query := fmt.Sprintf("SELECT %s, data FROM %s WHERE %s IN (SELECT value FROM json_each(?))", idColumnName[table], table, idColumnName[table]) + tx.Select(&rows, query, ids) + rtnMap := make(map[string]*T) + for _, row := range rows { + if row.Id == "" || row.Data == "" { + continue + } + r := TxReadJson[T](tx, row.Data) + if r == nil { + continue + } + rtnMap[(*r).GetId()] = r + } + return rtnMap, nil }) } -func TabInsert(ctx context.Context, tab *Tab, workspaceId string) error { - if tab.TabId == "" { - tab.TabId = uuid.New().String() - } +func DBDelete[T ObjectWithId](ctx context.Context, id string) error { return WithTx(ctx, func(tx *TxWrap) error { - query := "INSERT INTO tab (tabid, data) VALUES (?, ?)" - tx.Exec(query, tab.TabId, TxJson(tx, tab)) - return addTabToWorkspace(tx.Context(), workspaceId, tab.TabId) - }) -} - -func BlockGet(ctx context.Context, blockId string) (*Block, error) { - return WithTxRtn(ctx, func(tx *TxWrap) (*Block, error) { - query := "SELECT data FROM block WHERE blockid = ?" - jsonData := tx.GetString(query, blockId) - return TxReadJson[Block](tx, jsonData), nil - }) -} - -func BlockDelete(ctx context.Context, blockId string) error { - return WithTx(ctx, func(tx *TxWrap) error { - query := "DELETE FROM block WHERE blockid = ?" - tx.Exec(query, blockId) + var rtn T + table := typeToTable[reflect.TypeOf(rtn)] + if table == "" { + return fmt.Errorf("unknown table type: %T", rtn) + } + query := fmt.Sprintf("DELETE FROM %s WHERE %s = ?", table, idColumnName[table]) + tx.Exec(query, id) return nil }) } -func BlockInsert(ctx context.Context, block *Block) error { - if block.BlockId == "" { - block.BlockId = uuid.New().String() +func DBUpdate[T ObjectWithId](ctx context.Context, val *T) error { + if val == nil { + return fmt.Errorf("cannot update nil value") + } + if (*val).GetId() == "" { + return fmt.Errorf("cannot update %T value with empty id", val) } return WithTx(ctx, func(tx *TxWrap) error { - query := "INSERT INTO block (blockid, data) VALUES (?, ?)" - tx.Exec(query, block.BlockId, TxJson(tx, block)) + table := typeToTable[reflect.TypeOf(*val)] + if table == "" { + return fmt.Errorf("unknown table type: %T", *val) + } + query := fmt.Sprintf("UPDATE %s SET data = ? WHERE %s = ?", table, idColumnName[table]) + tx.Exec(query, TxJson(tx, val), (*val).GetId()) + return nil + }) +} + +func DBInsert[T ObjectWithId](ctx context.Context, val *T) error { + if val == nil { + return fmt.Errorf("cannot insert nil value") + } + if (*val).GetId() == "" { + return fmt.Errorf("cannot insert %T value with empty id", val) + } + return WithTx(ctx, func(tx *TxWrap) error { + table := typeToTable[reflect.TypeOf(*val)] + if table == "" { + return fmt.Errorf("unknown table type: %T", *val) + } + query := fmt.Sprintf("INSERT INTO %s (%s, data) VALUES (?, ?)", table, idColumnName[table]) + tx.Exec(query, (*val).GetId(), TxJson(tx, val)) return nil }) } diff --git a/pkg/wstore/wstore_dbsetup.go b/pkg/wstore/wstore_dbsetup.go index 3e58417d4..40ae85300 100644 --- a/pkg/wstore/wstore_dbsetup.go +++ b/pkg/wstore/wstore_dbsetup.go @@ -11,13 +11,11 @@ import ( "path" "time" - "github.com/golang-migrate/migrate/v4" - "github.com/golang-migrate/migrate/v4/source/iofs" "github.com/jmoiron/sqlx" "github.com/sawka/txwrap" + "github.com/wavetermdev/thenextwave/pkg/util/migrateutil" "github.com/wavetermdev/thenextwave/pkg/wavebase" - sqlite3migrate "github.com/golang-migrate/migrate/v4/database/sqlite3" dbfs "github.com/wavetermdev/thenextwave/db" ) @@ -35,7 +33,7 @@ func InitWStore() error { if err != nil { return err } - err = MigrateWStore() + err = migrateutil.Migrate("wstore", globalDB.DB, dbfs.WStoreMigrationFS, "migrations-wstore") if err != nil { return err } @@ -58,41 +56,6 @@ func MakeDB(ctx context.Context) (*sqlx.DB, error) { return rtn, nil } -func MigrateWStore() error { - return nil -} - -func MakeWStoreMigrate() (*migrate.Migrate, error) { - fsVar, err := iofs.New(dbfs.WStoreMigrationFS, "migrations-wstore") - if err != nil { - return nil, fmt.Errorf("opening iofs: %w", err) - } - mdriver, err := sqlite3migrate.WithInstance(globalDB.DB, &sqlite3migrate.Config{}) - if err != nil { - return nil, fmt.Errorf("making blockstore migration driver: %w", err) - } - m, err := migrate.NewWithInstance("iofs", fsVar, "sqlite3", mdriver) - if err != nil { - return nil, fmt.Errorf("making blockstore migration db[%s]: %w", GetDBName(), err) - } - return m, nil -} - -func GetMigrateVersion(m *migrate.Migrate) (uint, bool, error) { - if m == nil { - var err error - m, err = MakeWStoreMigrate() - if err != nil { - return 0, false, err - } - } - curVersion, dirty, err := m.Version() - if err == migrate.ErrNilVersion { - return 0, false, nil - } - return curVersion, dirty, err -} - func WithTx(ctx context.Context, fn func(tx *TxWrap) error) error { return txwrap.WithTx(ctx, globalDB, fn) }