diff --git a/README.md b/README.md
index 846e245a..03cd6006 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@ Now to run the dev version of the app:
wails3 dev
```
-You should see a very poorly laid out app :)
+You should see the app!
Now to build a MacOS application:
diff --git a/cmd/generate/main-generate.go b/cmd/generate/main-generate.go
new file mode 100644
index 00000000..54b4bd6f
--- /dev/null
+++ b/cmd/generate/main-generate.go
@@ -0,0 +1,26 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "fmt"
+ "reflect"
+
+ "github.com/wavetermdev/thenextwave/pkg/waveobj"
+ "github.com/wavetermdev/thenextwave/pkg/wstore"
+)
+
+func main() {
+ tsTypesMap := make(map[reflect.Type]string)
+ var waveObj waveobj.WaveObj
+ waveobj.GenerateTSType(reflect.TypeOf(waveobj.ORef{}), tsTypesMap)
+ waveobj.GenerateTSType(reflect.TypeOf(&waveObj).Elem(), tsTypesMap)
+ for _, rtype := range wstore.AllWaveObjTypes() {
+ waveobj.GenerateTSType(rtype, tsTypesMap)
+ }
+ for _, ts := range tsTypesMap {
+ fmt.Print(ts)
+ fmt.Print("\n")
+ }
+}
diff --git a/cmd/main-wsh.go b/cmd/wsh/main-wsh.go
similarity index 100%
rename from cmd/main-wsh.go
rename to cmd/wsh/main-wsh.go
diff --git a/db/db.go b/db/db.go
index b04d199a..b13b72fb 100644
--- a/db/db.go
+++ b/db/db.go
@@ -7,3 +7,6 @@ import "embed"
//go:embed migrations-blockstore/*.sql
var BlockstoreMigrationFS embed.FS
+
+//go:embed migrations-wstore/*.sql
+var WStoreMigrationFS embed.FS
diff --git a/db/migrations-wstore/000001_init.up.sql b/db/migrations-wstore/000001_init.up.sql
index 8880f644..7fa0c3bd 100644
--- a/db/migrations-wstore/000001_init.up.sql
+++ b/db/migrations-wstore/000001_init.up.sql
@@ -1,20 +1,29 @@
CREATE TABLE db_client (
- clientid varchar(36) PRIMARY KEY, -- unnecessary, but useful to have a PK
+ oid varchar(36) PRIMARY KEY,
+ version int NOT NULL,
+ data json NOT NULL
+);
+
+CREATE TABLE db_window (
+ oid varchar(36) PRIMARY KEY,
+ version int NOT NULL,
data json NOT NULL
);
CREATE TABLE db_workspace (
- workspaceid varchar(36) PRIMARY KEY,
+ oid varchar(36) PRIMARY KEY,
+ version int NOT NULL,
data json NOT NULL
);
CREATE TABLE db_tab (
- tabid varchar(36) PRIMARY KEY,
+ oid varchar(36) PRIMARY KEY,
+ version int NOT NULL,
data json NOT NULL
);
CREATE TABLE db_block (
- blockid varchar(36) PRIMARY KEY,
- tabid varchar(36) NOT NULL, -- the tab this block belongs to
+ oid varchar(36) PRIMARY KEY,
+ version int NOT NULL,
data json NOT NULL
);
diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx
index ab41992d..67681faa 100644
--- a/frontend/app/app.tsx
+++ b/frontend/app/app.tsx
@@ -9,6 +9,7 @@ import { Workspace } from "@/app/workspace/workspace";
import { globalStore, atoms } from "@/store/global";
import "../../public/style.less";
+import { CenteredDiv } from "./element/quickelems";
const App = () => {
return (
@@ -19,6 +20,16 @@ const App = () => {
};
const AppInner = () => {
+ const client = jotai.useAtomValue(atoms.client);
+ const windowData = jotai.useAtomValue(atoms.waveWindow);
+ 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 fe7af24b..328a86bf 100644
--- a/frontend/app/block/block.tsx
+++ b/frontend/app/block/block.tsx
@@ -3,8 +3,7 @@
import * as React from "react";
import * as jotai from "jotai";
-import { atoms, blockDataMap, removeBlockFromTab } from "@/store/global";
-
+import * as WOS from "@/store/wos";
import { TerminalView } from "@/app/view/term";
import { PreviewView } from "@/app/view/preview";
import { PlotView } from "@/app/view/plotview";
@@ -17,7 +16,7 @@ const Block = ({ tabId, blockId }: { tabId: string; blockId: string }) => {
const [dims, setDims] = React.useState({ width: 0, height: 0 });
function handleClose() {
- removeBlockFromTab(tabId, blockId);
+ WOS.DeleteBlock(blockId);
}
React.useEffect(() => {
@@ -31,10 +30,12 @@ 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);
- if (blockData.view === "term") {
+ const [blockData, blockDataLoading] = WOS.useWaveObjectValue
(WOS.makeORef("block", blockId));
+ if (blockDataLoading) {
+ blockElem = Loading...;
+ } else if (blockData.view === "term") {
blockElem = ;
} else if (blockData.view === "preview") {
blockElem = ;
diff --git a/frontend/app/element/quickelems.tsx b/frontend/app/element/quickelems.tsx
index 5de52a25..2777acfe 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 3c10cfa7..f6bc3af9 100644
--- a/frontend/app/store/global.ts
+++ b/frontend/app/store/global.ts
@@ -2,26 +2,56 @@
// SPDX-License-Identifier: Apache-2.0
import * as jotai from "jotai";
-import { atomFamily } from "jotai/utils";
-import { v4 as uuidv4 } from "uuid";
import * as rxjs from "rxjs";
-import type { WailsEvent } from "@wailsio/runtime/types/events";
import { Events } from "@wailsio/runtime";
-import { produce } from "immer";
-import { BlockService } from "@/bindings/blockservice";
+import * as WOS from "./wos";
const globalStore = jotai.createStore();
-
-const tabId1 = uuidv4();
-
-const tabArr: TabData[] = [{ name: "Tab 1", tabid: tabId1, blockIds: [] }];
-const blockDataMap = new Map
>();
-const blockAtomCache = new Map>>();
+const urlParams = new URLSearchParams(window.location.search);
+const globalWindowId = urlParams.get("windowid");
+const globalClientId = urlParams.get("clientid");
+const windowIdAtom = jotai.atom(null) as jotai.PrimitiveAtom;
+const clientIdAtom = jotai.atom(null) as jotai.PrimitiveAtom;
+globalStore.set(windowIdAtom, globalWindowId);
+globalStore.set(clientIdAtom, globalClientId);
+const uiContextAtom = jotai.atom((get) => {
+ const windowData = get(windowDataAtom);
+ const uiContext: UIContext = {
+ windowid: get(atoms.windowId),
+ activetabid: windowData.activetabid,
+ };
+ return uiContext;
+}) as jotai.Atom;
+const clientAtom: jotai.Atom = jotai.atom((get) => {
+ const clientId = get(clientIdAtom);
+ if (clientId == null) {
+ return null;
+ }
+ return WOS.getStaticObjectValue(WOS.makeORef("client", clientId), get);
+});
+const windowDataAtom: jotai.Atom = jotai.atom((get) => {
+ const windowId = get(windowIdAtom);
+ if (windowId == null) {
+ return null;
+ }
+ return WOS.getStaticObjectValue(WOS.makeORef("window", windowId), get);
+});
+const workspaceAtom: jotai.Atom = jotai.atom((get) => {
+ const windowData = get(windowDataAtom);
+ if (windowData == null) {
+ return null;
+ }
+ return WOS.getStaticObjectValue(WOS.makeORef("workspace", windowData.workspaceid), get);
+});
const atoms = {
- activeTabId: jotai.atom(tabId1),
- tabsAtom: jotai.atom(tabArr),
- blockDataMap: blockDataMap,
+ // initialized in wave.ts (will not be null inside of application)
+ windowId: windowIdAtom,
+ clientId: clientIdAtom,
+ uiContext: uiContextAtom,
+ client: clientAtom,
+ waveWindow: windowDataAtom,
+ workspace: workspaceAtom,
};
type SubjectWithRef = rxjs.Subject & { refCount: number; release: () => void };
@@ -60,19 +90,7 @@ Events.On("block:ptydata", (event: any) => {
subject.next(data);
});
-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);
- });
- globalStore.set(atoms.tabsAtom, newTabArr);
-}
-
-function removeBlock(blockId: string) {
- blockDataMap.delete(blockId);
- blockAtomCache.delete(blockId);
-}
+const blockAtomCache = new Map>>();
function useBlockAtom(blockId: string, name: string, makeFn: () => jotai.Atom): jotai.Atom {
let blockCache = blockAtomCache.get(blockId);
@@ -84,19 +102,9 @@ function useBlockAtom(blockId: string, name: string, makeFn: () => jotai.Atom
if (atom == null) {
atom = makeFn();
blockCache.set(name, atom);
+ console.log("New BlockAtom", blockId, name);
}
return atom as jotai.Atom;
}
-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);
- });
- globalStore.set(atoms.tabsAtom, newTabArr);
- removeBlock(blockId);
- BlockService.CloseBlock(blockId);
-}
-
-export { globalStore, atoms, getBlockSubject, addBlockIdToTab, blockDataMap, useBlockAtom, removeBlockFromTab };
+export { globalStore, atoms, getBlockSubject, useBlockAtom, WOS };
diff --git a/frontend/app/store/wos.ts b/frontend/app/store/wos.ts
new file mode 100644
index 00000000..5e76fb42
--- /dev/null
+++ b/frontend/app/store/wos.ts
@@ -0,0 +1,287 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+// WaveObjectStore
+
+import * as React from "react";
+import * as jotai from "jotai";
+import { Events } from "@wailsio/runtime";
+import { Call as $Call } from "@wailsio/runtime";
+import { globalStore, atoms } from "./global";
+
+type WaveObjectDataItemType = {
+ value: T;
+ loading: boolean;
+};
+
+type WaveObjectValue = {
+ pendingPromise: Promise;
+ dataAtom: jotai.PrimitiveAtom>;
+ refCount: number;
+ holdTime: number;
+};
+
+function splitORef(oref: string): [string, string] {
+ let parts = oref.split(":");
+ if (parts.length != 2) {
+ throw new Error("invalid oref");
+ }
+ return [parts[0], parts[1]];
+}
+
+function isBlank(str: string): boolean {
+ return str == null || str == "";
+}
+
+function isBlankNum(num: number): boolean {
+ return num == null || isNaN(num) || num == 0;
+}
+
+function isValidWaveObj(val: WaveObj): boolean {
+ if (val == null) {
+ return false;
+ }
+ if (isBlank(val.otype) || isBlank(val.oid) || isBlankNum(val.version)) {
+ return false;
+ }
+ return true;
+}
+
+function makeORef(otype: string, oid: string): string {
+ if (isBlank(otype) || isBlank(oid)) {
+ return null;
+ }
+ return `${otype}:${oid}`;
+}
+
+function GetObject(oref: string): Promise {
+ let prtn = $Call.ByName(
+ "github.com/wavetermdev/thenextwave/pkg/service/objectservice.ObjectService.GetObject",
+ oref
+ );
+ return prtn;
+}
+
+const waveObjectValueCache = new Map>();
+
+function clearWaveObjectCache() {
+ waveObjectValueCache.clear();
+}
+
+const defaultHoldTime = 5000; // 5-seconds
+
+function createWaveValueObject(oref: string, shouldFetch: boolean): WaveObjectValue {
+ const wov = { pendingPromise: null, dataAtom: null, refCount: 0, holdTime: Date.now() + 5000 };
+ wov.dataAtom = jotai.atom({ value: null, loading: true });
+ if (!shouldFetch) {
+ return wov;
+ }
+ let startTs = Date.now();
+ let localPromise = GetObject(oref);
+ wov.pendingPromise = localPromise;
+ localPromise.then((val) => {
+ if (wov.pendingPromise != localPromise) {
+ return;
+ }
+ const [otype, oid] = splitORef(oref);
+ if (val != null) {
+ if (val["otype"] != otype) {
+ throw new Error("GetObject returned wrong type");
+ }
+ if (val["oid"] != oid) {
+ throw new Error("GetObject returned wrong id");
+ }
+ }
+ wov.pendingPromise = null;
+ globalStore.set(wov.dataAtom, { value: val, loading: false });
+ console.log("WaveObj resolved", oref, Date.now() - startTs + "ms");
+ });
+ return wov;
+}
+
+function loadAndPinWaveObject(oref: string): Promise {
+ let wov = waveObjectValueCache.get(oref);
+ if (wov == null) {
+ wov = createWaveValueObject(oref, true);
+ waveObjectValueCache.set(oref, wov);
+ }
+ wov.refCount++;
+ if (wov.pendingPromise == null) {
+ const dataValue = globalStore.get(wov.dataAtom);
+ return Promise.resolve(dataValue.value);
+ }
+ return wov.pendingPromise;
+}
+
+function useWaveObjectValueWithSuspense(oref: string): T {
+ let wov = waveObjectValueCache.get(oref);
+ if (wov == null) {
+ wov = createWaveValueObject(oref, true);
+ waveObjectValueCache.set(oref, wov);
+ }
+ React.useEffect(() => {
+ wov.refCount++;
+ return () => {
+ wov.refCount--;
+ };
+ }, [oref]);
+ const dataValue = jotai.useAtomValue(wov.dataAtom);
+ if (dataValue.loading) {
+ throw wov.pendingPromise;
+ }
+ return dataValue.value;
+}
+
+function useWaveObjectValue(oref: string): [T, boolean] {
+ let wov = waveObjectValueCache.get(oref);
+ if (wov == null) {
+ wov = createWaveValueObject(oref, true);
+ waveObjectValueCache.set(oref, wov);
+ }
+ React.useEffect(() => {
+ wov.refCount++;
+ return () => {
+ wov.refCount--;
+ };
+ }, [oref]);
+ const atomVal = jotai.useAtomValue(wov.dataAtom);
+ return [atomVal.value, atomVal.loading];
+}
+
+function useWaveObject(oref: string): [T, boolean, (T) => void] {
+ let wov = waveObjectValueCache.get(oref);
+ if (wov == null) {
+ wov = createWaveValueObject(oref, true);
+ waveObjectValueCache.set(oref, wov);
+ }
+ React.useEffect(() => {
+ wov.refCount++;
+ return () => {
+ wov.refCount--;
+ };
+ }, [oref]);
+ const [atomVal, setAtomVal] = jotai.useAtom(wov.dataAtom);
+ const simpleSet = (val: T) => {
+ setAtomVal({ value: val, loading: false });
+ };
+ return [atomVal.value, atomVal.loading, simpleSet];
+}
+
+function updateWaveObject(update: WaveObjUpdate) {
+ if (update == null) {
+ return;
+ }
+ let oref = makeORef(update.otype, update.oid);
+ let wov = waveObjectValueCache.get(oref);
+ if (wov == null) {
+ wov = createWaveValueObject(oref, false);
+ waveObjectValueCache.set(oref, wov);
+ }
+ if (update.updatetype == "delete") {
+ console.log("WaveObj deleted", oref);
+ globalStore.set(wov.dataAtom, { value: null, loading: false });
+ } else {
+ if (!isValidWaveObj(update.obj)) {
+ console.log("invalid wave object update", update);
+ return;
+ }
+ let curValue: WaveObjectDataItemType = globalStore.get(wov.dataAtom);
+ if (curValue.value != null && curValue.value.version >= update.obj.version) {
+ return;
+ }
+ console.log("WaveObj updated", oref);
+ globalStore.set(wov.dataAtom, { value: update.obj, loading: false });
+ }
+ wov.holdTime = Date.now() + defaultHoldTime;
+ return;
+}
+
+function updateWaveObjects(vals: WaveObjUpdate[]) {
+ for (let val of vals) {
+ updateWaveObject(val);
+ }
+}
+
+function cleanWaveObjectCache() {
+ let now = Date.now();
+ for (let [oref, wov] of waveObjectValueCache) {
+ if (wov.refCount == 0 && wov.holdTime < now) {
+ waveObjectValueCache.delete(oref);
+ }
+ }
+}
+
+Events.On("waveobj:update", (event: any) => {
+ const data: WaveObjUpdate[] = event?.data;
+ if (data == null) {
+ return;
+ }
+ if (!Array.isArray(data)) {
+ console.log("invalid waveobj:update, not an array", data);
+ return;
+ }
+ if (data.length == 0) {
+ return;
+ }
+ updateWaveObjects(data);
+});
+
+function wrapObjectServiceCall(fnName: string, ...args: any[]): Promise {
+ const uiContext = globalStore.get(atoms.uiContext);
+ const startTs = Date.now();
+ let prtn = $Call.ByName(
+ "github.com/wavetermdev/thenextwave/pkg/service/objectservice.ObjectService." + fnName,
+ uiContext,
+ ...args
+ );
+ prtn = prtn.then((val) => {
+ console.log("Call", fnName, Date.now() - startTs + "ms");
+ if (val.updates) {
+ updateWaveObjects(val.updates);
+ }
+ return val;
+ });
+ return prtn;
+}
+
+function getStaticObjectValue(oref: string, getFn: jotai.Getter): T {
+ let wov = waveObjectValueCache.get(oref);
+ if (wov == null) {
+ return null;
+ }
+ const atomVal = getFn(wov.dataAtom);
+ return atomVal.value;
+}
+
+export function AddTabToWorkspace(tabName: string, activateTab: boolean): Promise<{ tabId: string }> {
+ return wrapObjectServiceCall("AddTabToWorkspace", tabName, activateTab);
+}
+
+export function SetActiveTab(tabId: string): Promise {
+ return wrapObjectServiceCall("SetActiveTab", tabId);
+}
+
+export function CreateBlock(blockDef: BlockDef, rtOpts: RuntimeOpts): Promise<{ blockId: string }> {
+ return wrapObjectServiceCall("CreateBlock", blockDef, rtOpts);
+}
+
+export function DeleteBlock(blockId: string): Promise {
+ return wrapObjectServiceCall("DeleteBlock", blockId);
+}
+
+export function CloseTab(tabId: string): Promise {
+ return wrapObjectServiceCall("CloseTab", tabId);
+}
+
+export {
+ makeORef,
+ useWaveObject,
+ useWaveObjectValue,
+ useWaveObjectValueWithSuspense,
+ loadAndPinWaveObject,
+ clearWaveObjectCache,
+ updateWaveObject,
+ updateWaveObjects,
+ cleanWaveObjectCache,
+ getStaticObjectValue,
+};
diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx
index 6b762936..609d94ec 100644
--- a/frontend/app/tab/tab.tsx
+++ b/frontend/app/tab/tab.tsx
@@ -5,21 +5,29 @@ import * as React from "react";
import * as jotai from "jotai";
import { Block } from "@/app/block/block";
import { atoms } from "@/store/global";
+import * as WOS from "@/store/wos";
import "./tab.less";
+import { CenteredDiv, CenteredLoadingDiv } from "../element/quickelems";
const TabContent = ({ tabId }: { tabId: string }) => {
- const tabs = jotai.useAtomValue(atoms.tabsAtom);
- const tabData = tabs.find((tab) => tab.tabid === tabId);
+ const [tabData, tabLoading] = WOS.useWaveObjectValue(WOS.makeORef("tab", tabId));
+ if (tabLoading) {
+ return ;
+ }
if (!tabData) {
- return Tab not found
;
+ return (
+
+ Tab Not Found
+
+ );
}
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 3390cd89..dabd2bdb 100644
--- a/frontend/app/view/preview.tsx
+++ b/frontend/app/view/preview.tsx
@@ -3,14 +3,16 @@
import * as React from "react";
import * as jotai from "jotai";
-import { atoms, blockDataMap, useBlockAtom } from "@/store/global";
+import { atoms, useBlockAtom } from "@/store/global";
import { Markdown } from "@/element/markdown";
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 WOS from "@/store/wos";
import "./view.less";
+import { first } from "rxjs";
const MaxFileSize = 1024 * 1024 * 10; // 10MB
@@ -61,10 +63,17 @@ function DirectoryPreview({ contentAtom }: { contentAtom: jotai.Atom
= blockDataMap.get(blockId);
+ const blockData = WOS.useWaveObjectValueWithSuspense(WOS.makeORef("block", blockId));
+ if (blockData == null) {
+ return (
+
+ Block Not Found
+
+ );
+ }
const fileNameAtom = useBlockAtom(blockId, "preview:filename", () =>
jotai.atom((get) => {
- return get(blockDataAtom)?.meta?.file;
+ return blockData?.meta?.file;
})
);
const statFileAtom = useBlockAtom(blockId, "preview:statfile", () =>
diff --git a/frontend/app/workspace/workspace.less b/frontend/app/workspace/workspace.less
index 76ddc8a3..a37ced56 100644
--- a/frontend/app/workspace/workspace.less
+++ b/frontend/app/workspace/workspace.less
@@ -55,9 +55,24 @@
height: 100%;
border-right: 1px solid var(--border-color);
cursor: pointer;
+ position: relative;
+
&.active {
background-color: var(--highlight-bg-color);
}
+
+ &.active:hover .tab-close {
+ display: block;
+ }
+
+ .tab-close {
+ position: absolute;
+ display: none;
+ padding: 5px;
+ right: 2px;
+ top: 5px;
+ cursor: pointer;
+ }
}
.tab-add {
diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx
index e61c67f5..c3b8abce 100644
--- a/frontend/app/workspace/workspace.tsx
+++ b/frontend/app/workspace/workspace.tsx
@@ -5,37 +5,46 @@ import * as React from "react";
import * as jotai from "jotai";
import { TabContent } from "@/app/tab/tab";
import { clsx } from "clsx";
-import { atoms, addBlockIdToTab, blockDataMap } from "@/store/global";
-import { v4 as uuidv4 } from "uuid";
-import { BlockService } from "@/bindings/blockservice";
+import { atoms } from "@/store/global";
+import * as WOS from "@/store/wos";
+import { CenteredLoadingDiv, CenteredDiv } from "../element/quickelems";
import "./workspace.less";
-function Tab({ tab }: { tab: TabData }) {
- const [activeTab, setActiveTab] = jotai.useAtom(atoms.activeTabId);
+function Tab({ tabId }: { tabId: string }) {
+ const windowData = jotai.useAtomValue(atoms.waveWindow);
+ const [tabData, tabLoading] = WOS.useWaveObjectValue(WOS.makeORef("tab", tabId));
+ function setActiveTab() {
+ WOS.SetActiveTab(tabId);
+ }
+ function handleCloseTab() {
+ WOS.CloseTab(tabId);
+ }
return (
- setActiveTab(tab.tabid)}>
- {tab.name}
+
setActiveTab()}
+ >
+
handleCloseTab()}>
+
+
+
+
+ {tabData?.name ?? "..."}
);
}
-function TabBar() {
- const [tabData, setTabData] = jotai.useAtom(atoms.tabsAtom);
- const [activeTab, setActiveTab] = jotai.useAtom(atoms.activeTabId);
- const tabs = jotai.useAtomValue(atoms.tabsAtom);
-
+function TabBar({ workspace }: { workspace: Workspace }) {
function handleAddTab() {
- const newTabId = uuidv4();
- const newTabName = "Tab " + (tabData.length + 1);
- setTabData([...tabData, { name: newTabName, tabid: newTabId, blockIds: [] }]);
- setActiveTab(newTabId);
+ const newTabName = `Tab-${workspace.tabids.length + 1}`;
+ WOS.AddTabToWorkspace(newTabName, true);
}
-
+ const tabIds = workspace?.tabids ?? [];
return (
- {tabs.map((tab, idx) => {
- return
;
+ {tabIds.map((tabid, idx) => {
+ return
;
})}
handleAddTab()}>
@@ -45,14 +54,12 @@ function TabBar() {
}
function Widgets() {
- const activeTabId = jotai.useAtomValue(atoms.activeTabId);
+ const windowData = jotai.useAtomValue(atoms.waveWindow);
+ const activeTabId = windowData.activetabid;
async function createBlock(blockDef: BlockDef) {
- const rtOpts = { termsize: { rows: 25, cols: 80 } };
- const rtnBlock: BlockData = (await BlockService.CreateBlock(blockDef, rtOpts)) as BlockData;
- const newBlockAtom = jotai.atom(rtnBlock);
- blockDataMap.set(rtnBlock.blockid, newBlockAtom);
- addBlockIdToTab(activeTabId, rtnBlock.blockid);
+ const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } };
+ await WOS.CreateBlock(blockDef, rtOpts);
}
async function clickTerminal() {
@@ -72,7 +79,7 @@ function Widgets() {
}
async function clickPlot() {
- const plotDef = {
+ const plotDef: BlockDef = {
view: "plot",
};
createBlock(plotDef);
@@ -105,17 +112,25 @@ function Widgets() {
);
}
-function Workspace() {
- const activeTabId = jotai.useAtomValue(atoms.activeTabId);
+function WorkspaceElem() {
+ const windowData = jotai.useAtomValue(atoms.waveWindow);
+ const activeTabId = windowData?.activetabid;
+ const ws = jotai.useAtomValue(atoms.workspace);
return (
-
+
-
-
+ {activeTabId == "" ? (
+ No Active Tab
+ ) : (
+ <>
+
+
+ >
+ )}
);
}
-export { Workspace };
+export { WorkspaceElem as Workspace };
diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts
index f3f95c4c..e94ff3e0 100644
--- a/frontend/types/custom.d.ts
+++ b/frontend/types/custom.d.ts
@@ -2,21 +2,42 @@
// SPDX-License-Identifier: Apache-2.0
declare global {
- type MetaDataType = Record
;
-
- type TabData = {
- name: string;
- tabid: string;
- blockIds: string[];
+ type UIContext = {
+ windowid: string;
+ activetabid: string;
};
- type BlockData = {
- blockid: string;
+ type ORef = {
+ otype: string;
+ oid: string;
+ };
+
+ type WaveObj = {
+ otype: string;
+ oid: string;
+ version: number;
+ };
+
+ type WaveObjUpdate = {
+ updatetype: "update" | "delete";
+ otype: string;
+ oid: string;
+ obj?: WaveObj;
+ };
+
+ type Block = WaveObj & {
blockdef: BlockDef;
controller: string;
- controllerstatus: string;
view: string;
- meta?: MetaDataType;
+ meta?: { [key: string]: any };
+ runtimeopts?: RuntimeOpts;
+ };
+
+ type BlockDef = {
+ controller?: string;
+ view?: string;
+ files?: { [key: string]: FileDef };
+ meta?: { [key: string]: any };
};
type FileDef = {
@@ -24,14 +45,62 @@ declare global {
path?: string;
url?: string;
content?: string;
- meta?: MetaDataType;
+ meta?: { [key: string]: any };
};
- type BlockDef = {
- controller?: string;
- view: string;
- files?: FileDef[];
- meta?: MetaDataType;
+ type TermSize = {
+ rows: number;
+ cols: number;
+ };
+
+ type Client = {
+ otype: string;
+ oid: string;
+ version: number;
+ mainwindowid: string;
+ };
+
+ type Tab = {
+ otype: string;
+ oid: string;
+ version: number;
+ name: string;
+ blockids: string[];
+ };
+
+ type Point = {
+ x: number;
+ y: number;
+ };
+
+ type WinSize = {
+ width: number;
+ height: number;
+ };
+
+ type Workspace = {
+ otype: string;
+ oid: string;
+ version: number;
+ name: string;
+ tabids: string[];
+ };
+
+ type RuntimeOpts = {
+ termsize?: TermSize;
+ winsize?: WinSize;
+ };
+
+ type WaveWindow = {
+ otype: string;
+ oid: string;
+ version: number;
+ workspaceid: string;
+ activetabid: string;
+ activeblockmap: { [key: string]: string };
+ pos: Point;
+ winsize: WinSize;
+ lastfocusts: number;
};
}
diff --git a/frontend/wave.ts b/frontend/wave.ts
index 06c44d83..98bdb412 100644
--- a/frontend/wave.ts
+++ b/frontend/wave.ts
@@ -5,14 +5,34 @@ 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 WOS from "@/store/wos";
+import * as wailsRuntime from "@wailsio/runtime";
+import * as wstore from "@/gopkg/wstore";
+import * as gdata from "@/store/global";
+import { immerable } from "immer";
+
+const urlParams = new URLSearchParams(window.location.search);
+const windowId = urlParams.get("windowid");
+const clientId = urlParams.get("clientid");
loadFonts();
-document.addEventListener("DOMContentLoaded", () => {
+console.log("Wave Starting");
+
+document.addEventListener("DOMContentLoaded", async () => {
+ console.log("DOMContentLoaded");
+ // ensures client/window are loaded into the cache before rendering
+ await WOS.loadAndPinWaveObject(WOS.makeORef("client", clientId));
+ const waveWindow = await WOS.loadAndPinWaveObject(WOS.makeORef("window", windowId));
+ await WOS.loadAndPinWaveObject(WOS.makeORef("workspace", waveWindow.workspaceid));
let reactElem = React.createElement(App, null, null);
let elem = document.getElementById("main");
let root = createRoot(elem);
document.fonts.ready.then(() => {
+ console.log("Wave First Render");
root.render(reactElem);
});
});
diff --git a/go.mod b/go.mod
index 7ea697ba..e630cfd5 100644
--- a/go.mod
+++ b/go.mod
@@ -10,6 +10,7 @@ require (
github.com/google/uuid v1.4.0
github.com/jmoiron/sqlx v1.4.0
github.com/mattn/go-sqlite3 v1.14.22
+ github.com/mitchellh/mapstructure v1.5.0
github.com/sawka/txwrap v0.2.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.0
github.com/wavetermdev/waveterm/wavesrv v0.0.0-20240508181017-d07068c09d94
diff --git a/go.sum b/go.sum
index 217bbb7f..a346f06c 100644
--- a/go.sum
+++ b/go.sum
@@ -91,6 +91,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
diff --git a/main.go b/main.go
index 27ee260b..cb923c3a 100644
--- a/main.go
+++ b/main.go
@@ -6,17 +6,23 @@ package main
// Note, main.go needs to be in the root of the project for the go:embed directive to work.
import (
+ "context"
"embed"
+ "fmt"
"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/service/objectservice"
"github.com/wavetermdev/thenextwave/pkg/wavebase"
+ "github.com/wavetermdev/thenextwave/pkg/wstore"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
@@ -32,10 +38,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()
@@ -47,7 +53,11 @@ func createAppMenu(app *application.App) *application.Menu {
return menu
}
-func createWindow(app *application.App) {
+func createWindow(windowData *wstore.Window, app *application.App) {
+ client, err := wstore.DBGetSingleton[*wstore.Client](context.Background())
+ if err != nil {
+ panic(fmt.Errorf("error getting client data: %w", err))
+ }
window := app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{
Title: "Wave Terminal",
Mac: application.MacWindow{
@@ -55,13 +65,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.OID + "&clientid=" + client.OID,
+ X: windowData.Pos.X,
+ Y: windowData.Pos.Y,
+ Width: windowData.WinSize.Width,
+ Height: windowData.WinSize.Height,
})
- eventbus.RegisterWailsWindow(window)
+ eventbus.RegisterWailsWindow(window, windowData.OID)
window.On(events.Common.WindowClosing, func(event *application.WindowEvent) {
eventbus.UnregisterWailsWindow(window.ID())
})
+ window.Show()
}
type waveAssetHandler struct {
@@ -104,6 +119,16 @@ func main() {
log.Printf("error initializing blockstore: %v\n", err)
return
}
+ err = wstore.InitWStore()
+ if err != nil {
+ 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",
@@ -111,6 +136,8 @@ func main() {
Services: []application.Service{
application.NewService(&fileservice.FileService{}),
application.NewService(&blockservice.BlockService{}),
+ application.NewService(&clientservice.ClientService{}),
+ application.NewService(&objectservice.ObjectService{}),
},
Icon: appIcon,
Assets: application.AssetOptions{
@@ -124,7 +151,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 53ccded9..d78f4edb 100644
--- a/pkg/blockcontroller/blockcontroller.go
+++ b/pkg/blockcontroller/blockcontroller.go
@@ -4,15 +4,16 @@
package blockcontroller
import (
+ "context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"sync"
+ "time"
"github.com/creack/pty"
- "github.com/google/uuid"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wavetermdev/thenextwave/pkg/eventbus"
"github.com/wavetermdev/thenextwave/pkg/shellexec"
@@ -24,6 +25,8 @@ const (
BlockController_Cmd = "cmd"
)
+const DefaultTimeout = 2 * time.Second
+
var globalLock = &sync.Mutex{}
var blockControllerMap = make(map[string]*BlockController)
@@ -32,11 +35,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,38 +60,6 @@ func jsonDeepCopy(val map[string]any) (map[string]any, error) {
return rtn, nil
}
-func CreateBlock(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,
- View: bdef.View,
- RuntimeOpts: rtOpts,
- }
- var err error
- blockData.Meta, err = jsonDeepCopy(bdef.Meta)
- if err != nil {
- return nil, fmt.Errorf("error copying meta: %w", err)
- }
- wstore.BlockMap.Set(blockId, blockData)
- if blockData.Controller != "" {
- StartBlockController(blockId, blockData)
- }
- return blockData, nil
-}
-
-func CloseBlock(blockId string) {
- bc := GetBlockController(blockId)
- if bc == nil {
- return
- }
- bc.Close()
- close(bc.InputCh)
- wstore.BlockMap.Delete(blockId)
-}
-
func (bc *BlockController) setShellProc(shellProc *shellexec.ShellProc) error {
bc.Lock.Lock()
defer bc.Lock.Unlock()
@@ -179,10 +157,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 +171,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
@@ -218,26 +196,42 @@ 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
+func StartBlockController(ctx context.Context, blockId string) error {
+ blockData, err := wstore.DBMustGet[*wstore.Block](ctx, blockId)
+ if err != nil {
+ return fmt.Errorf("error getting block: %w", err)
+ }
+ if blockData.Controller == "" {
+ // nothing to start
+ return nil
+ }
+ if blockData.Controller != BlockController_Shell {
+ return fmt.Errorf("unknown controller %q", blockData.Controller)
}
globalLock.Lock()
defer globalLock.Unlock()
if _, ok := blockControllerMap[blockId]; ok {
- return
+ // already running
+ return nil
}
bc := &BlockController{
Lock: &sync.Mutex{},
BlockId: blockId,
+ Status: "init",
InputCh: make(chan BlockCommand),
}
blockControllerMap[blockId] = bc
- go bc.Run(bdata)
+ go bc.Run(blockData)
+ return nil
+}
+
+func StopBlockController(blockId string) {
+ bc := GetBlockController(blockId)
+ if bc == nil {
+ return
+ }
+ bc.Close()
+ close(bc.InputCh)
}
func GetBlockController(blockId string) *BlockController {
@@ -246,31 +240,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(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 c6e87974..a4937ab9 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/eventbus/eventbus.go b/pkg/eventbus/eventbus.go
index 8300d76b..41a8933f 100644
--- a/pkg/eventbus/eventbus.go
+++ b/pkg/eventbus/eventbus.go
@@ -5,11 +5,13 @@ package eventbus
import (
"errors"
+ "fmt"
"log"
"runtime/debug"
"sync"
"github.com/wailsapp/wails/v3/pkg/application"
+ "github.com/wavetermdev/thenextwave/pkg/waveobj"
)
const EventBufferSize = 50
@@ -24,9 +26,16 @@ type WindowEvent struct {
Event application.WailsEvent
}
+type WindowWatchData struct {
+ Window *application.WebviewWindow
+ WaveWindowId string
+ WailsWindowId uint
+ WatchedORefs map[waveobj.ORef]bool
+}
+
var globalLock = &sync.Mutex{}
var wailsApp *application.App
-var wailsWindowMap = make(map[uint]*application.WebviewWindow)
+var wailsWindowMap = make(map[uint]*WindowWatchData)
func Start() {
go processEvents()
@@ -42,10 +51,18 @@ func RegisterWailsApp(app *application.App) {
wailsApp = app
}
-func RegisterWailsWindow(window *application.WebviewWindow) {
+func RegisterWailsWindow(window *application.WebviewWindow, windowId string) {
globalLock.Lock()
defer globalLock.Unlock()
- wailsWindowMap[window.ID()] = window
+ if _, found := wailsWindowMap[window.ID()]; found {
+ panic(fmt.Errorf("wails window already registered with eventbus: %d", window.ID()))
+ }
+ wailsWindowMap[window.ID()] = &WindowWatchData{
+ Window: window,
+ WailsWindowId: window.ID(),
+ WaveWindowId: "",
+ WatchedORefs: make(map[waveobj.ORef]bool),
+ }
}
func UnregisterWailsWindow(windowId uint) {
@@ -56,18 +73,18 @@ func UnregisterWailsWindow(windowId uint) {
func emitEventToWindow(event WindowEvent) {
globalLock.Lock()
- window := wailsWindowMap[event.WindowId]
+ wdata := wailsWindowMap[event.WindowId]
globalLock.Unlock()
- if window != nil {
- window.DispatchWailsEvent(&event.Event)
+ if wdata != nil {
+ wdata.Window.DispatchWailsEvent(&event.Event)
}
}
func emitEventToAllWindows(event *application.WailsEvent) {
globalLock.Lock()
wins := make([]*application.WebviewWindow, 0, len(wailsWindowMap))
- for _, window := range wailsWindowMap {
- wins = append(wins, window)
+ for _, wdata := range wailsWindowMap {
+ wins = append(wins, wdata.Window)
}
globalLock.Unlock()
for _, window := range wins {
@@ -79,6 +96,25 @@ func SendEvent(event application.WailsEvent) {
EventCh <- event
}
+func findWindowIdsByORef(oref waveobj.ORef) []uint {
+ globalLock.Lock()
+ defer globalLock.Unlock()
+ var ids []uint
+ for _, wdata := range wailsWindowMap {
+ if wdata.WatchedORefs[oref] {
+ ids = append(ids, wdata.WailsWindowId)
+ }
+ }
+ return ids
+}
+
+func SendORefEvent(oref waveobj.ORef, event application.WailsEvent) {
+ wins := findWindowIdsByORef(oref)
+ for _, windowId := range wins {
+ SendWindowEvent(windowId, event)
+ }
+}
+
func SendEventNonBlocking(event application.WailsEvent) error {
select {
case EventCh <- event:
diff --git a/pkg/service/blockservice/blockservice.go b/pkg/service/blockservice/blockservice.go
index 81df911f..e5fe2522 100644
--- a/pkg/service/blockservice/blockservice.go
+++ b/pkg/service/blockservice/blockservice.go
@@ -6,52 +6,14 @@ package blockservice
import (
"fmt"
"strings"
+ "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) (map[string]any, error) {
- var bdef wstore.BlockDef
- err := utilfn.JsonMapToStruct(bdefMap, &bdef)
- if err != nil {
- return nil, fmt.Errorf("error unmarshalling BlockDef: %w", err)
- }
- var rtOpts wstore.RuntimeOpts
- err = utilfn.JsonMapToStruct(rtOptsMap, &rtOpts)
- if err != nil {
- return nil, fmt.Errorf("error unmarshalling RuntimeOpts: %w", err)
- }
- blockData, err := blockcontroller.CreateBlock(&bdef, &rtOpts)
- if err != nil {
- return nil, fmt.Errorf("error creating block: %w", err)
- }
- rtnMap, err := utilfn.StructToJsonMap(blockData)
- if err != nil {
- return nil, fmt.Errorf("error marshalling BlockData: %w", err)
- }
- return rtnMap, nil
-}
-
-func (bs *BlockService) CloseBlock(blockId string) {
- blockcontroller.CloseBlock(blockId)
-}
-
-func (bs *BlockService) GetBlockData(blockId string) (map[string]any, error) {
- blockData := wstore.BlockMap.Get(blockId)
- if blockData == nil {
- return nil, nil
- }
- rtnMap, err := utilfn.StructToJsonMap(blockData)
- if err != nil {
- return nil, fmt.Errorf("error marshalling BlockData: %w", err)
- }
- return rtnMap, nil
-
-}
+const DefaultTimeout = 2 * time.Second
func (bs *BlockService) SendCommand(blockId string, cmdMap map[string]any) error {
cmd, err := blockcontroller.ParseCmdMap(cmdMap)
diff --git a/pkg/service/clientservice/clientservice.go b/pkg/service/clientservice/clientservice.go
new file mode 100644
index 00000000..5d0a0c6d
--- /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/service/objectservice/objectservice.go b/pkg/service/objectservice/objectservice.go
new file mode 100644
index 00000000..0a27a0a5
--- /dev/null
+++ b/pkg/service/objectservice/objectservice.go
@@ -0,0 +1,181 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package objectservice
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/wavetermdev/thenextwave/pkg/blockcontroller"
+ "github.com/wavetermdev/thenextwave/pkg/waveobj"
+ "github.com/wavetermdev/thenextwave/pkg/wstore"
+)
+
+type ObjectService struct{}
+
+const DefaultTimeout = 2 * time.Second
+
+func parseORef(oref string) (*waveobj.ORef, error) {
+ fields := strings.Split(oref, ":")
+ if len(fields) != 2 {
+ return nil, fmt.Errorf("invalid object reference: %q", oref)
+ }
+ return &waveobj.ORef{OType: fields[0], OID: fields[1]}, nil
+}
+
+func (svc *ObjectService) GetObject(orefStr string) (any, error) {
+ oref, err := parseORef(orefStr)
+ if err != nil {
+ return nil, err
+ }
+ ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
+ defer cancelFn()
+ obj, err := wstore.DBGetORef(ctx, *oref)
+ if err != nil {
+ return nil, fmt.Errorf("error getting object: %w", err)
+ }
+ rtn, err := waveobj.ToJsonMap(obj)
+ return rtn, err
+}
+
+func (svc *ObjectService) GetObjects(orefStrArr []string) (any, error) {
+ ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
+ defer cancelFn()
+
+ var orefArr []waveobj.ORef
+ for _, orefStr := range orefStrArr {
+ orefObj, err := parseORef(orefStr)
+ if err != nil {
+ return nil, err
+ }
+ orefArr = append(orefArr, *orefObj)
+ }
+ return wstore.DBSelectORefs(ctx, orefArr)
+}
+
+func updatesRtn(ctx context.Context, rtnVal map[string]any) (any, error) {
+ updates := wstore.ContextGetUpdates(ctx)
+ if len(updates) == 0 {
+ return nil, nil
+ }
+ updateArr := make([]wstore.WaveObjUpdate, 0, len(updates))
+ for _, update := range updates {
+ updateArr = append(updateArr, update)
+ }
+ jval, err := json.Marshal(updateArr)
+ if err != nil {
+ return nil, fmt.Errorf("error converting updates to JSON: %w", err)
+ }
+ if rtnVal == nil {
+ rtnVal = make(map[string]any)
+ }
+ rtnVal["updates"] = json.RawMessage(jval)
+ return rtnVal, nil
+}
+
+func (svc *ObjectService) AddTabToWorkspace(uiContext wstore.UIContext, tabName string, activateTab bool) (any, error) {
+ ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
+ defer cancelFn()
+ ctx = wstore.ContextWithUpdates(ctx)
+ windowData, err := wstore.DBMustGet[*wstore.Window](ctx, uiContext.WindowId)
+ if err != nil {
+ return nil, fmt.Errorf("error getting window: %w", err)
+ }
+ tab, err := wstore.CreateTab(ctx, windowData.WorkspaceId, tabName)
+ if err != nil {
+ return nil, fmt.Errorf("error creating tab: %w", err)
+ }
+ if activateTab {
+ err = wstore.SetActiveTab(ctx, uiContext.WindowId, tab.OID)
+ if err != nil {
+ return nil, fmt.Errorf("error setting active tab: %w", err)
+ }
+ }
+ rtn := make(map[string]any)
+ rtn["tabid"] = waveobj.GetOID(tab)
+ return updatesRtn(ctx, rtn)
+}
+
+func (svc *ObjectService) SetActiveTab(uiContext wstore.UIContext, tabId string) (any, error) {
+ ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
+ defer cancelFn()
+ ctx = wstore.ContextWithUpdates(ctx)
+ err := wstore.SetActiveTab(ctx, uiContext.WindowId, tabId)
+ if err != nil {
+ return nil, fmt.Errorf("error setting active tab: %w", err)
+ }
+ return updatesRtn(ctx, nil)
+}
+
+func (svc *ObjectService) CreateBlock(uiContext wstore.UIContext, blockDef *wstore.BlockDef, rtOpts *wstore.RuntimeOpts) (any, error) {
+ if uiContext.ActiveTabId == "" {
+ return nil, fmt.Errorf("no active tab")
+ }
+ ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
+ defer cancelFn()
+ ctx = wstore.ContextWithUpdates(ctx)
+ blockData, err := wstore.CreateBlock(ctx, uiContext.ActiveTabId, blockDef, rtOpts)
+ if err != nil {
+ return nil, fmt.Errorf("error creating block: %w", err)
+ }
+ if blockData.Controller != "" {
+ err = blockcontroller.StartBlockController(ctx, blockData.OID)
+ if err != nil {
+ return nil, fmt.Errorf("error starting block controller: %w", err)
+ }
+ }
+ rtn := make(map[string]any)
+ rtn["blockid"] = blockData.OID
+ return updatesRtn(ctx, rtn)
+}
+
+func (svc *ObjectService) DeleteBlock(uiContext wstore.UIContext, blockId string) (any, error) {
+ ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
+ defer cancelFn()
+ ctx = wstore.ContextWithUpdates(ctx)
+ err := wstore.DeleteBlock(ctx, uiContext.ActiveTabId, blockId)
+ if err != nil {
+ return nil, fmt.Errorf("error deleting block: %w", err)
+ }
+ blockcontroller.StopBlockController(blockId)
+ return updatesRtn(ctx, nil)
+}
+
+func (svc *ObjectService) CloseTab(uiContext wstore.UIContext, tabId string) (any, error) {
+ ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
+ defer cancelFn()
+ ctx = wstore.ContextWithUpdates(ctx)
+ window, err := wstore.DBMustGet[*wstore.Window](ctx, uiContext.WindowId)
+ if err != nil {
+ return nil, fmt.Errorf("error getting window: %w", err)
+ }
+ tab, err := wstore.DBMustGet[*wstore.Tab](ctx, tabId)
+ if err != nil {
+ return nil, fmt.Errorf("error getting tab: %w", err)
+ }
+ for _, blockId := range tab.BlockIds {
+ blockcontroller.StopBlockController(blockId)
+ }
+ err = wstore.CloseTab(ctx, window.WorkspaceId, tabId)
+ if err != nil {
+ return nil, fmt.Errorf("error closing tab: %w", err)
+ }
+ if window.ActiveTabId == tabId {
+ ws, err := wstore.DBMustGet[*wstore.Workspace](ctx, window.WorkspaceId)
+ if err != nil {
+ return nil, fmt.Errorf("error getting workspace: %w", err)
+ }
+ var newActiveTabId string
+ if len(ws.TabIds) > 0 {
+ newActiveTabId = ws.TabIds[0]
+ } else {
+ newActiveTabId = ""
+ }
+ wstore.SetActiveTab(ctx, uiContext.WindowId, newActiveTabId)
+ }
+ return updatesRtn(ctx, nil)
+}
diff --git a/pkg/util/migrateutil/migrateutil.go b/pkg/util/migrateutil/migrateutil.go
new file mode 100644
index 00000000..c27f5a32
--- /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/waveobj/waveobj.go b/pkg/waveobj/waveobj.go
new file mode 100644
index 00000000..3473b122
--- /dev/null
+++ b/pkg/waveobj/waveobj.go
@@ -0,0 +1,346 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package waveobj
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "reflect"
+ "strings"
+ "sync"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+const (
+ OTypeKeyName = "otype"
+ OIDKeyName = "oid"
+ VersionKeyName = "version"
+
+ OIDGoFieldName = "OID"
+ VersionGoFieldName = "Version"
+)
+
+type ORef struct {
+ OType string `json:"otype"`
+ OID string `json:"oid"`
+}
+
+type WaveObj interface {
+ GetOType() string // should not depend on object state (should work with nil value)
+}
+
+type waveObjDesc struct {
+ RType reflect.Type
+ OIDField reflect.StructField
+ VersionField reflect.StructField
+}
+
+var waveObjMap = sync.Map{}
+var waveObjRType = reflect.TypeOf((*WaveObj)(nil)).Elem()
+
+func RegisterType(rtype reflect.Type) {
+ if rtype.Kind() != reflect.Ptr {
+ panic(fmt.Sprintf("wave object must be a pointer for %v", rtype))
+ }
+ if !rtype.Implements(waveObjRType) {
+ panic(fmt.Sprintf("wave object must implement WaveObj for %v", rtype))
+ }
+ waveObj := reflect.Zero(rtype).Interface().(WaveObj)
+ otype := waveObj.GetOType()
+ if otype == "" {
+ panic(fmt.Sprintf("otype is empty for %v", rtype))
+ }
+ oidField, found := rtype.Elem().FieldByName(OIDGoFieldName)
+ if !found {
+ panic(fmt.Sprintf("missing OID field for %v", rtype))
+ }
+ if oidField.Type.Kind() != reflect.String {
+ panic(fmt.Sprintf("OID field must be string for %v", rtype))
+ }
+ if oidField.Tag.Get("json") != OIDKeyName {
+ panic(fmt.Sprintf("OID field json tag must be %q for %v", OIDKeyName, rtype))
+ }
+ versionField, found := rtype.Elem().FieldByName(VersionGoFieldName)
+ if !found {
+ panic(fmt.Sprintf("missing Version field for %v", rtype))
+ }
+ if versionField.Type.Kind() != reflect.Int {
+ panic(fmt.Sprintf("Version field must be int for %v", rtype))
+ }
+ if versionField.Tag.Get("json") != VersionKeyName {
+ panic(fmt.Sprintf("Version field json tag must be %q for %v", VersionKeyName, rtype))
+ }
+ _, found = waveObjMap.Load(otype)
+ if found {
+ panic(fmt.Sprintf("otype %q already registered", otype))
+ }
+ waveObjMap.Store(otype, &waveObjDesc{
+ RType: rtype,
+ OIDField: oidField,
+ VersionField: versionField,
+ })
+}
+
+func getWaveObjDesc(otype string) *waveObjDesc {
+ desc, _ := waveObjMap.Load(otype)
+ if desc == nil {
+ return nil
+ }
+ return desc.(*waveObjDesc)
+}
+
+func GetOID(waveObj WaveObj) string {
+ desc := getWaveObjDesc(waveObj.GetOType())
+ if desc == nil {
+ return ""
+ }
+ return reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.OIDField.Index).String()
+}
+
+func SetOID(waveObj WaveObj, oid string) {
+ desc := getWaveObjDesc(waveObj.GetOType())
+ if desc == nil {
+ return
+ }
+ reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.OIDField.Index).SetString(oid)
+}
+
+func GetVersion(waveObj WaveObj) int {
+ desc := getWaveObjDesc(waveObj.GetOType())
+ if desc == nil {
+ return 0
+ }
+ return int(reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.VersionField.Index).Int())
+}
+
+func SetVersion(waveObj WaveObj, version int) {
+ desc := getWaveObjDesc(waveObj.GetOType())
+ if desc == nil {
+ return
+ }
+ reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.VersionField.Index).SetInt(int64(version))
+}
+
+func ToJsonMap(w WaveObj) (map[string]any, error) {
+ m := make(map[string]any)
+ dconfig := &mapstructure.DecoderConfig{
+ Result: &m,
+ TagName: "json",
+ }
+ decoder, err := mapstructure.NewDecoder(dconfig)
+ if err != nil {
+ return nil, err
+ }
+ err = decoder.Decode(w)
+ if err != nil {
+ return nil, err
+ }
+ m[OTypeKeyName] = w.GetOType()
+ m[OIDKeyName] = GetOID(w)
+ m[VersionKeyName] = GetVersion(w)
+ return m, nil
+}
+
+func ToJson(w WaveObj) ([]byte, error) {
+ m, err := ToJsonMap(w)
+ if err != nil {
+ return nil, err
+ }
+ return json.Marshal(m)
+}
+
+func FromJson(data []byte) (WaveObj, error) {
+ var m map[string]any
+ err := json.Unmarshal(data, &m)
+ if err != nil {
+ return nil, err
+ }
+ otype, ok := m[OTypeKeyName].(string)
+ if !ok {
+ return nil, fmt.Errorf("missing otype")
+ }
+ desc := getWaveObjDesc(otype)
+ if desc == nil {
+ return nil, fmt.Errorf("unknown otype: %s", otype)
+ }
+ wobj := reflect.Zero(desc.RType).Interface().(WaveObj)
+ dconfig := &mapstructure.DecoderConfig{
+ Result: &wobj,
+ TagName: "json",
+ }
+ decoder, err := mapstructure.NewDecoder(dconfig)
+ if err != nil {
+ return nil, err
+ }
+ err = decoder.Decode(m)
+ if err != nil {
+ return nil, err
+ }
+ return wobj, nil
+}
+
+func FromJsonGen[T WaveObj](data []byte) (T, error) {
+ obj, err := FromJson(data)
+ if err != nil {
+ var zero T
+ return zero, err
+ }
+ rtn, ok := obj.(T)
+ if !ok {
+ var zero T
+ return zero, fmt.Errorf("type mismatch got %T, expected %T", obj, zero)
+ }
+ return rtn, nil
+}
+
+func getTSFieldName(field reflect.StructField) string {
+ jsonTag := field.Tag.Get("json")
+ if jsonTag != "" {
+ parts := strings.Split(jsonTag, ",")
+ namePart := parts[0]
+ if namePart != "" {
+ if namePart == "-" {
+ return ""
+ }
+ return namePart
+ }
+ // if namePart is empty, still uses default
+ }
+ return field.Name
+}
+
+func isFieldOmitEmpty(field reflect.StructField) bool {
+ jsonTag := field.Tag.Get("json")
+ if jsonTag != "" {
+ parts := strings.Split(jsonTag, ",")
+ if len(parts) > 1 {
+ for _, part := range parts[1:] {
+ if part == "omitempty" {
+ return true
+ }
+ }
+ }
+ }
+ return false
+}
+
+func typeToTSType(t reflect.Type) (string, []reflect.Type) {
+ switch t.Kind() {
+ case reflect.String:
+ return "string", nil
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
+ reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
+ reflect.Float32, reflect.Float64:
+ return "number", nil
+ case reflect.Bool:
+ return "boolean", nil
+ case reflect.Slice, reflect.Array:
+ elemType, subTypes := typeToTSType(t.Elem())
+ if elemType == "" {
+ return "", nil
+ }
+ return fmt.Sprintf("%s[]", elemType), subTypes
+ case reflect.Map:
+ if t.Key().Kind() != reflect.String {
+ return "", nil
+ }
+ elemType, subTypes := typeToTSType(t.Elem())
+ if elemType == "" {
+ return "", nil
+ }
+ return fmt.Sprintf("{[key: string]: %s}", elemType), subTypes
+ case reflect.Struct:
+ return t.Name(), []reflect.Type{t}
+ case reflect.Ptr:
+ return typeToTSType(t.Elem())
+ case reflect.Interface:
+ return "any", nil
+ default:
+ return "", nil
+ }
+}
+
+var tsRenameMap = map[string]string{
+ "Window": "WaveWindow",
+}
+
+func generateTSTypeInternal(rtype reflect.Type) (string, []reflect.Type) {
+ var buf bytes.Buffer
+ waveObjType := reflect.TypeOf((*WaveObj)(nil)).Elem()
+ tsTypeName := rtype.Name()
+ if tsRename, ok := tsRenameMap[tsTypeName]; ok {
+ tsTypeName = tsRename
+ }
+ var isWaveObj bool
+ if rtype.Implements(waveObjType) || reflect.PointerTo(rtype).Implements(waveObjType) {
+ isWaveObj = true
+ buf.WriteString(fmt.Sprintf("type %s = WaveObj & {\n", tsTypeName))
+ } else {
+ buf.WriteString(fmt.Sprintf("type %s = {\n", tsTypeName))
+ }
+ var subTypes []reflect.Type
+ for i := 0; i < rtype.NumField(); i++ {
+ field := rtype.Field(i)
+ if field.PkgPath != "" {
+ continue
+ }
+ fieldName := getTSFieldName(field)
+ if fieldName == "" {
+ continue
+ }
+ if isWaveObj && (fieldName == OTypeKeyName || fieldName == OIDKeyName || fieldName == VersionKeyName) {
+ continue
+ }
+ optMarker := ""
+ if isFieldOmitEmpty(field) {
+ optMarker = "?"
+ }
+ tsTypeTag := field.Tag.Get("tstype")
+ if tsTypeTag != "" {
+ buf.WriteString(fmt.Sprintf(" %s%s: %s;\n", fieldName, optMarker, tsTypeTag))
+ continue
+ }
+ tsType, fieldSubTypes := typeToTSType(field.Type)
+ if tsType == "" {
+ continue
+ }
+ subTypes = append(subTypes, fieldSubTypes...)
+ buf.WriteString(fmt.Sprintf(" %s%s: %s;\n", fieldName, optMarker, tsType))
+ }
+ buf.WriteString("};\n")
+ return buf.String(), subTypes
+}
+
+func GenerateWaveObjTSType() string {
+ var buf bytes.Buffer
+ buf.WriteString("type WaveObj = {\n")
+ buf.WriteString(" otype: string;\n")
+ buf.WriteString(" oid: string;\n")
+ buf.WriteString(" version: number;\n")
+ buf.WriteString("};\n")
+ return buf.String()
+}
+
+func GenerateTSType(rtype reflect.Type, tsTypesMap map[reflect.Type]string) {
+ if rtype == nil {
+ return
+ }
+ if rtype.Kind() == reflect.Ptr {
+ rtype = rtype.Elem()
+ }
+ if _, ok := tsTypesMap[rtype]; ok {
+ return
+ }
+ if rtype == waveObjRType {
+ tsTypesMap[rtype] = GenerateWaveObjTSType()
+ return
+ }
+ tsType, subTypes := generateTSTypeInternal(rtype)
+ tsTypesMap[rtype] = tsType
+ for _, subType := range subTypes {
+ GenerateTSType(subType, tsTypesMap)
+ }
+}
diff --git a/pkg/waveobj/waveobj_test.go b/pkg/waveobj/waveobj_test.go
new file mode 100644
index 00000000..d7db8059
--- /dev/null
+++ b/pkg/waveobj/waveobj_test.go
@@ -0,0 +1,30 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package waveobj
+
+import (
+ "log"
+ "reflect"
+ "testing"
+)
+
+type TestBlock struct {
+ BlockId string `json:"blockid" waveobj:"oid"`
+ Name string `json:"name"`
+}
+
+func (TestBlock) GetOType() string {
+ return "block"
+}
+
+func TestGenerate(t *testing.T) {
+ log.Printf("Testing Generate\n")
+ tsMap := make(map[reflect.Type]string)
+ var waveObj WaveObj
+ GenerateTSType(reflect.TypeOf(&waveObj).Elem(), tsMap)
+ GenerateTSType(reflect.TypeOf(TestBlock{}), tsMap)
+ for k, v := range tsMap {
+ log.Printf("Type: %v, TS:\n%s\n", k, v)
+ }
+}
diff --git a/pkg/wstore/wstore.go b/pkg/wstore/wstore.go
index f1c5466f..40d98493 100644
--- a/pkg/wstore/wstore.go
+++ b/pkg/wstore/wstore.go
@@ -4,45 +4,233 @@
package wstore
import (
+ "bytes"
+ "context"
+ "encoding/json"
"fmt"
- "sync"
+ "log"
+ "reflect"
+ "time"
"github.com/google/uuid"
"github.com/wavetermdev/thenextwave/pkg/shellexec"
- "github.com/wavetermdev/thenextwave/pkg/util/ds"
+ "github.com/wavetermdev/thenextwave/pkg/waveobj"
)
-var WorkspaceMap = ds.NewSyncMap[*Workspace]()
-var TabMap = ds.NewSyncMap[*Tab]()
-var BlockMap = ds.NewSyncMap[*Block]()
+var waveObjUpdateKey = struct{}{}
+
+func init() {
+ for _, rtype := range AllWaveObjTypes() {
+ waveobj.RegisterType(rtype)
+ }
+}
+
+type contextUpdatesType struct {
+ UpdatesStack []map[waveobj.ORef]WaveObjUpdate
+}
+
+func dumpUpdateStack(updates *contextUpdatesType) {
+ log.Printf("dumpUpdateStack len:%d\n", len(updates.UpdatesStack))
+ for idx, update := range updates.UpdatesStack {
+ var buf bytes.Buffer
+ buf.WriteString(fmt.Sprintf(" [%d]:", idx))
+ for k := range update {
+ buf.WriteString(fmt.Sprintf(" %s:%s", k.OType, k.OID))
+ }
+ buf.WriteString("\n")
+ log.Print(buf.String())
+ }
+}
+
+func ContextWithUpdates(ctx context.Context) context.Context {
+ updatesVal := ctx.Value(waveObjUpdateKey)
+ if updatesVal != nil {
+ return ctx
+ }
+ return context.WithValue(ctx, waveObjUpdateKey, &contextUpdatesType{
+ UpdatesStack: []map[waveobj.ORef]WaveObjUpdate{make(map[waveobj.ORef]WaveObjUpdate)},
+ })
+}
+
+func ContextGetUpdates(ctx context.Context) map[waveobj.ORef]WaveObjUpdate {
+ updatesVal := ctx.Value(waveObjUpdateKey)
+ if updatesVal == nil {
+ return nil
+ }
+ updates := updatesVal.(*contextUpdatesType)
+ if len(updates.UpdatesStack) == 1 {
+ return updates.UpdatesStack[0]
+ }
+ rtn := make(map[waveobj.ORef]WaveObjUpdate)
+ for _, update := range updates.UpdatesStack {
+ for k, v := range update {
+ rtn[k] = v
+ }
+ }
+ return rtn
+}
+
+func ContextGetUpdate(ctx context.Context, oref waveobj.ORef) *WaveObjUpdate {
+ updatesVal := ctx.Value(waveObjUpdateKey)
+ if updatesVal == nil {
+ return nil
+ }
+ updates := updatesVal.(*contextUpdatesType)
+ for idx := len(updates.UpdatesStack) - 1; idx >= 0; idx-- {
+ if obj, ok := updates.UpdatesStack[idx][oref]; ok {
+ return &obj
+ }
+ }
+ return nil
+}
+
+func ContextAddUpdate(ctx context.Context, update WaveObjUpdate) {
+ updatesVal := ctx.Value(waveObjUpdateKey)
+ if updatesVal == nil {
+ return
+ }
+ updates := updatesVal.(*contextUpdatesType)
+ oref := waveobj.ORef{
+ OType: update.OType,
+ OID: update.OID,
+ }
+ updates.UpdatesStack[len(updates.UpdatesStack)-1][oref] = update
+}
+
+func ContextUpdatesBeginTx(ctx context.Context) context.Context {
+ updatesVal := ctx.Value(waveObjUpdateKey)
+ if updatesVal == nil {
+ return ctx
+ }
+ updates := updatesVal.(*contextUpdatesType)
+ updates.UpdatesStack = append(updates.UpdatesStack, make(map[waveobj.ORef]WaveObjUpdate))
+ return ctx
+}
+
+func ContextUpdatesCommitTx(ctx context.Context) {
+ updatesVal := ctx.Value(waveObjUpdateKey)
+ if updatesVal == nil {
+ return
+ }
+ updates := updatesVal.(*contextUpdatesType)
+ if len(updates.UpdatesStack) <= 1 {
+ panic(fmt.Errorf("no updates transaction to commit"))
+ }
+ // merge the last two updates
+ curUpdateMap := updates.UpdatesStack[len(updates.UpdatesStack)-1]
+ prevUpdateMap := updates.UpdatesStack[len(updates.UpdatesStack)-2]
+ for k, v := range curUpdateMap {
+ prevUpdateMap[k] = v
+ }
+ updates.UpdatesStack = updates.UpdatesStack[:len(updates.UpdatesStack)-1]
+}
+
+func ContextUpdatesRollbackTx(ctx context.Context) {
+ updatesVal := ctx.Value(waveObjUpdateKey)
+ if updatesVal == nil {
+ return
+ }
+ updates := updatesVal.(*contextUpdatesType)
+ if len(updates.UpdatesStack) <= 1 {
+ panic(fmt.Errorf("no updates transaction to rollback"))
+ }
+ updates.UpdatesStack = updates.UpdatesStack[:len(updates.UpdatesStack)-1]
+}
+
+type WaveObjTombstone struct {
+ OType string `json:"otype"`
+ OID string `json:"oid"`
+}
+
+const (
+ UpdateType_Update = "update"
+ UpdateType_Delete = "delete"
+)
+
+type WaveObjUpdate struct {
+ UpdateType string `json:"updatetype"`
+ OType string `json:"otype"`
+ OID string `json:"oid"`
+ Obj waveobj.WaveObj `json:"obj,omitempty"`
+}
+
+func (update WaveObjUpdate) MarshalJSON() ([]byte, error) {
+ rtn := make(map[string]any)
+ rtn["updatetype"] = update.UpdateType
+ rtn["otype"] = update.OType
+ rtn["oid"] = update.OID
+ if update.Obj != nil {
+ var err error
+ rtn["obj"], err = waveobj.ToJsonMap(update.Obj)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return json.Marshal(rtn)
+}
+
+type UIContext struct {
+ WindowId string `json:"windowid"`
+ ActiveTabId string `json:"activetabid"`
+}
type Client struct {
- DefaultWorkspaceId string `json:"defaultworkspaceid"`
+ OID string `json:"oid"`
+ Version int `json:"version"`
+ MainWindowId string `json:"mainwindowid"`
+}
+
+func (*Client) GetOType() string {
+ return "client"
+}
+
+func AllWaveObjTypes() []reflect.Type {
+ return []reflect.Type{
+ reflect.TypeOf(&Client{}),
+ reflect.TypeOf(&Window{}),
+ reflect.TypeOf(&Workspace{}),
+ reflect.TypeOf(&Tab{}),
+ reflect.TypeOf(&Block{}),
+ }
+}
+
+// stores the ui-context of the window
+// workspaceid, active tab, active block within each tab, window size, etc.
+type Window struct {
+ OID string `json:"oid"`
+ Version int `json:"version"`
+ 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 (*Window) GetOType() string {
+ return "window"
}
type Workspace struct {
- Lock *sync.Mutex `json:"-"`
- WorkspaceId string `json:"workspaceid"`
- TabIds []string `json:"tabids"`
+ OID string `json:"oid"`
+ Version int `json:"version"`
+ Name string `json:"name"`
+ TabIds []string `json:"tabids"`
}
-func (ws *Workspace) WithLock(f func()) {
- ws.Lock.Lock()
- defer ws.Lock.Unlock()
- f()
+func (*Workspace) GetOType() string {
+ return "workspace"
}
type Tab struct {
- Lock *sync.Mutex `json:"-"`
- TabId string `json:"tabid"`
- Name string `json:"name"`
- BlockIds []string `json:"blockids"`
+ OID string `json:"oid"`
+ Version int `json:"version"`
+ Name string `json:"name"`
+ BlockIds []string `json:"blockids"`
}
-func (tab *Tab) WithLock(f func()) {
- tab.Lock.Lock()
- defer tab.Lock.Unlock()
- f()
+func (*Tab) GetOType() string {
+ return "tab"
}
type FileDef struct {
@@ -54,7 +242,7 @@ type FileDef struct {
}
type BlockDef struct {
- Controller string `json:"controller"`
+ Controller string `json:"controller,omitempty"`
View string `json:"view,omitempty"`
Files map[string]*FileDef `json:"files,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
@@ -65,56 +253,205 @@ 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"`
+ OID string `json:"oid"`
+ Version int `json:"version"`
+ 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) WithLock(f func()) {
- b.Lock.Lock()
- defer b.Lock.Unlock()
- f()
+func (*Block) GetOType() string {
+ return "block"
}
-func CreateTab(workspaceId string, name string) (*Tab, error) {
- tab := &Tab{
- Lock: &sync.Mutex{},
- TabId: uuid.New().String(),
- Name: name,
- BlockIds: []string{},
- }
- TabMap.Set(tab.TabId, tab)
- ws := WorkspaceMap.Get(workspaceId)
- if ws == nil {
- return nil, fmt.Errorf("workspace not found: %q", workspaceId)
- }
- ws.WithLock(func() {
- ws.TabIds = append(ws.TabIds, tab.TabId)
+func CreateTab(ctx context.Context, workspaceId string, name string) (*Tab, error) {
+ return WithTxRtn(ctx, func(tx *TxWrap) (*Tab, error) {
+ ws, _ := DBGet[*Workspace](tx.Context(), workspaceId)
+ if ws == nil {
+ return nil, fmt.Errorf("workspace not found: %q", workspaceId)
+ }
+ tab := &Tab{
+ OID: uuid.New().String(),
+ Name: name,
+ BlockIds: []string{},
+ }
+ ws.TabIds = append(ws.TabIds, tab.OID)
+ DBInsert(tx.Context(), tab)
+ DBUpdate(tx.Context(), ws)
+ return tab, nil
})
- return tab, nil
}
-func CreateWorkspace() (*Workspace, error) {
+func CreateWorkspace(ctx context.Context) (*Workspace, error) {
ws := &Workspace{
- Lock: &sync.Mutex{},
- WorkspaceId: uuid.New().String(),
- TabIds: []string{},
- }
- WorkspaceMap.Set(ws.WorkspaceId, ws)
- _, err := CreateTab(ws.WorkspaceId, "Tab 1")
- if err != nil {
- return nil, err
+ OID: uuid.New().String(),
+ TabIds: []string{},
}
+ DBInsert(ctx, ws)
return ws, nil
}
+
+func SetActiveTab(ctx context.Context, windowId string, tabId string) error {
+ return WithTx(ctx, func(tx *TxWrap) error {
+ window, _ := DBGet[*Window](tx.Context(), windowId)
+ if window == nil {
+ return fmt.Errorf("window not found: %q", windowId)
+ }
+ if tabId != "" {
+ tab, _ := DBGet[*Tab](tx.Context(), tabId)
+ if tab == nil {
+ return fmt.Errorf("tab not found: %q", tabId)
+ }
+ }
+ window.ActiveTabId = tabId
+ DBUpdate(tx.Context(), window)
+ return nil
+ })
+}
+
+func CreateBlock(ctx context.Context, tabId string, blockDef *BlockDef, rtOpts *RuntimeOpts) (*Block, error) {
+ return WithTxRtn(ctx, func(tx *TxWrap) (*Block, error) {
+ tab, _ := DBGet[*Tab](tx.Context(), tabId)
+ if tab == nil {
+ return nil, fmt.Errorf("tab not found: %q", tabId)
+ }
+ blockId := uuid.New().String()
+ blockData := &Block{
+ OID: blockId,
+ BlockDef: blockDef,
+ Controller: blockDef.Controller,
+ View: blockDef.View,
+ RuntimeOpts: rtOpts,
+ Meta: blockDef.Meta,
+ }
+ DBInsert(tx.Context(), blockData)
+ tab.BlockIds = append(tab.BlockIds, blockId)
+ DBUpdate(tx.Context(), tab)
+ return blockData, nil
+ })
+}
+
+func findStringInSlice(slice []string, val string) int {
+ for idx, v := range slice {
+ if v == val {
+ return idx
+ }
+ }
+ return -1
+}
+
+func DeleteBlock(ctx context.Context, tabId string, blockId string) error {
+ return WithTx(ctx, func(tx *TxWrap) error {
+ tab, _ := DBGet[*Tab](tx.Context(), tabId)
+ if tab == nil {
+ return fmt.Errorf("tab not found: %q", tabId)
+ }
+ blockIdx := findStringInSlice(tab.BlockIds, blockId)
+ if blockIdx == -1 {
+ return nil
+ }
+ tab.BlockIds = append(tab.BlockIds[:blockIdx], tab.BlockIds[blockIdx+1:]...)
+ DBUpdate(tx.Context(), tab)
+ DBDelete(tx.Context(), "block", blockId)
+ return nil
+ })
+
+}
+
+func CloseTab(ctx context.Context, workspaceId string, tabId string) error {
+ return WithTx(ctx, func(tx *TxWrap) error {
+ ws, _ := DBGet[*Workspace](tx.Context(), workspaceId)
+ if ws == nil {
+ return fmt.Errorf("workspace not found: %q", workspaceId)
+ }
+ tab, _ := DBGet[*Tab](tx.Context(), tabId)
+ if tab == nil {
+ return fmt.Errorf("tab not found: %q", tabId)
+ }
+ tabIdx := findStringInSlice(ws.TabIds, tabId)
+ if tabIdx == -1 {
+ return nil
+ }
+ ws.TabIds = append(ws.TabIds[:tabIdx], ws.TabIds[tabIdx+1:]...)
+ DBUpdate(tx.Context(), ws)
+ DBDelete(tx.Context(), "tab", tabId)
+ for _, blockId := range tab.BlockIds {
+ DBDelete(tx.Context(), "block", blockId)
+ }
+ return nil
+ })
+}
+
+func EnsureInitialData() error {
+ // does not need to run in a transaction since it is called on startup
+ ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancelFn()
+ clientCount, err := DBGetCount[*Client](ctx)
+ if err != nil {
+ return fmt.Errorf("error getting client count: %w", err)
+ }
+ if clientCount > 0 {
+ return nil
+ }
+ windowId := uuid.New().String()
+ workspaceId := uuid.New().String()
+ tabId := uuid.New().String()
+ client := &Client{
+ OID: uuid.New().String(),
+ MainWindowId: windowId,
+ }
+ err = DBInsert(ctx, client)
+ if err != nil {
+ return fmt.Errorf("error inserting client: %w", err)
+ }
+ window := &Window{
+ OID: 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{
+ OID: workspaceId,
+ Name: "default",
+ TabIds: []string{tabId},
+ }
+ err = DBInsert(ctx, ws)
+ if err != nil {
+ return fmt.Errorf("error inserting workspace: %w", err)
+ }
+ tab := &Tab{
+ OID: tabId,
+ Name: "Tab-1",
+ BlockIds: []string{},
+ }
+ err = DBInsert(ctx, tab)
+ if err != nil {
+ return fmt.Errorf("error inserting tab: %w", err)
+ }
+ return nil
+}
diff --git a/pkg/wstore/wstore_dbops.go b/pkg/wstore/wstore_dbops.go
new file mode 100644
index 00000000..e912fe8d
--- /dev/null
+++ b/pkg/wstore/wstore_dbops.go
@@ -0,0 +1,203 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package wstore
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/wavetermdev/thenextwave/pkg/waveobj"
+)
+
+var ErrNotFound = fmt.Errorf("not found")
+
+func waveObjTableName(w waveobj.WaveObj) string {
+ return "db_" + w.GetOType()
+}
+
+func tableNameFromOType(otype string) string {
+ return "db_" + otype
+}
+
+func tableNameGen[T waveobj.WaveObj]() string {
+ var zeroObj T
+ return tableNameFromOType(zeroObj.GetOType())
+}
+
+func getOTypeGen[T waveobj.WaveObj]() string {
+ var zeroObj T
+ return zeroObj.GetOType()
+}
+
+func DBGetCount[T waveobj.WaveObj](ctx context.Context) (int, error) {
+ return WithTxRtn(ctx, func(tx *TxWrap) (int, error) {
+ table := tableNameGen[T]()
+ query := fmt.Sprintf("SELECT count(*) FROM %s", table)
+ return tx.GetInt(query), nil
+ })
+}
+
+type idDataType struct {
+ OId string
+ Version int
+ Data []byte
+}
+
+func genericCastWithErr[T any](v any, err error) (T, error) {
+ if err != nil {
+ var zeroVal T
+ return zeroVal, err
+ }
+ return v.(T), err
+}
+
+func DBGetSingleton[T waveobj.WaveObj](ctx context.Context) (T, error) {
+ rtn, err := DBGetSingletonByType(ctx, getOTypeGen[T]())
+ return genericCastWithErr[T](rtn, err)
+}
+
+func DBGetSingletonByType(ctx context.Context, otype string) (waveobj.WaveObj, error) {
+ return WithTxRtn(ctx, func(tx *TxWrap) (waveobj.WaveObj, error) {
+ table := tableNameFromOType(otype)
+ query := fmt.Sprintf("SELECT oid, version, data FROM %s LIMIT 1", table)
+ var row idDataType
+ tx.Get(&row, query)
+ rtn, err := waveobj.FromJson(row.Data)
+ if err != nil {
+ return rtn, err
+ }
+ waveobj.SetVersion(rtn, row.Version)
+ return rtn, nil
+ })
+}
+
+func DBGet[T waveobj.WaveObj](ctx context.Context, id string) (T, error) {
+ rtn, err := DBGetORef(ctx, waveobj.ORef{OType: getOTypeGen[T](), OID: id})
+ return genericCastWithErr[T](rtn, err)
+}
+
+func DBMustGet[T waveobj.WaveObj](ctx context.Context, id string) (T, error) {
+ rtn, err := DBGetORef(ctx, waveobj.ORef{OType: getOTypeGen[T](), OID: id})
+ if err != nil {
+ var zeroVal T
+ return zeroVal, err
+ }
+ if rtn == nil {
+ var zeroVal T
+ return zeroVal, ErrNotFound
+ }
+ return rtn.(T), nil
+}
+
+func DBGetORef(ctx context.Context, oref waveobj.ORef) (waveobj.WaveObj, error) {
+ return WithTxRtn(ctx, func(tx *TxWrap) (waveobj.WaveObj, error) {
+ table := tableNameFromOType(oref.OType)
+ query := fmt.Sprintf("SELECT oid, version, data FROM %s WHERE oid = ?", table)
+ var row idDataType
+ tx.Get(&row, query, oref.OID)
+ rtn, err := waveobj.FromJson(row.Data)
+ if err != nil {
+ return rtn, err
+ }
+ waveobj.SetVersion(rtn, row.Version)
+ return rtn, nil
+ })
+}
+
+func dbSelectOIDs(ctx context.Context, otype string, oids []string) ([]waveobj.WaveObj, error) {
+ return WithTxRtn(ctx, func(tx *TxWrap) ([]waveobj.WaveObj, error) {
+ table := tableNameFromOType(otype)
+ query := fmt.Sprintf("SELECT oid, version, data FROM %s WHERE oid IN (SELECT value FROM json_each(?))", table)
+ var rows []idDataType
+ tx.Select(&rows, query, oids)
+ rtn := make([]waveobj.WaveObj, 0, len(rows))
+ for _, row := range rows {
+ waveObj, err := waveobj.FromJson(row.Data)
+ if err != nil {
+ return nil, err
+ }
+ waveobj.SetVersion(waveObj, row.Version)
+ rtn = append(rtn, waveObj)
+ }
+ return rtn, nil
+ })
+}
+
+func DBSelectORefs(ctx context.Context, orefs []waveobj.ORef) ([]waveobj.WaveObj, error) {
+ oidsByType := make(map[string][]string)
+ for _, oref := range orefs {
+ oidsByType[oref.OType] = append(oidsByType[oref.OType], oref.OID)
+ }
+ return WithTxRtn(ctx, func(tx *TxWrap) ([]waveobj.WaveObj, error) {
+ rtn := make([]waveobj.WaveObj, 0, len(orefs))
+ for otype, oids := range oidsByType {
+ rtnArr, err := dbSelectOIDs(tx.Context(), otype, oids)
+ if err != nil {
+ return nil, err
+ }
+ rtn = append(rtn, rtnArr...)
+ }
+ return rtn, nil
+ })
+}
+
+func DBSelectMap[T waveobj.WaveObj](ctx context.Context, ids []string) (map[string]T, error) {
+ rtnArr, err := dbSelectOIDs(ctx, getOTypeGen[T](), ids)
+ if err != nil {
+ return nil, err
+ }
+ rtnMap := make(map[string]T)
+ for _, obj := range rtnArr {
+ rtnMap[waveobj.GetOID(obj)] = obj.(T)
+ }
+ return rtnMap, nil
+}
+
+func DBDelete(ctx context.Context, otype string, id string) error {
+ return WithTx(ctx, func(tx *TxWrap) error {
+ table := tableNameFromOType(otype)
+ query := fmt.Sprintf("DELETE FROM %s WHERE oid = ?", table)
+ tx.Exec(query, id)
+ ContextAddUpdate(ctx, WaveObjUpdate{UpdateType: UpdateType_Delete, OType: otype, OID: id})
+ return nil
+ })
+}
+
+func DBUpdate(ctx context.Context, val waveobj.WaveObj) error {
+ oid := waveobj.GetOID(val)
+ if oid == "" {
+ return fmt.Errorf("cannot update %T value with empty id", val)
+ }
+ jsonData, err := waveobj.ToJson(val)
+ if err != nil {
+ return err
+ }
+ return WithTx(ctx, func(tx *TxWrap) error {
+ table := waveObjTableName(val)
+ query := fmt.Sprintf("UPDATE %s SET data = ?, version = version+1 WHERE oid = ? RETURNING version", table)
+ newVersion := tx.GetInt(query, jsonData, oid)
+ waveobj.SetVersion(val, newVersion)
+ ContextAddUpdate(ctx, WaveObjUpdate{UpdateType: UpdateType_Update, OType: val.GetOType(), OID: oid, Obj: val})
+ return nil
+ })
+}
+
+func DBInsert(ctx context.Context, val waveobj.WaveObj) error {
+ oid := waveobj.GetOID(val)
+ if oid == "" {
+ return fmt.Errorf("cannot insert %T value with empty id", val)
+ }
+ jsonData, err := waveobj.ToJson(val)
+ if err != nil {
+ return err
+ }
+ return WithTx(ctx, func(tx *TxWrap) error {
+ table := waveObjTableName(val)
+ waveobj.SetVersion(val, 1)
+ query := fmt.Sprintf("INSERT INTO %s (oid, version, data) VALUES (?, ?, ?)", table)
+ tx.Exec(query, oid, 1, jsonData)
+ ContextAddUpdate(ctx, WaveObjUpdate{UpdateType: UpdateType_Update, OType: val.GetOType(), OID: oid, Obj: val})
+ return nil
+ })
+}
diff --git a/pkg/wstore/wstore_dbsetup.go b/pkg/wstore/wstore_dbsetup.go
index 1b97352f..f79e2cea 100644
--- a/pkg/wstore/wstore_dbsetup.go
+++ b/pkg/wstore/wstore_dbsetup.go
@@ -12,7 +12,10 @@ import (
"github.com/jmoiron/sqlx"
"github.com/sawka/txwrap"
+ "github.com/wavetermdev/thenextwave/pkg/util/migrateutil"
"github.com/wavetermdev/thenextwave/pkg/wavebase"
+
+ dbfs "github.com/wavetermdev/thenextwave/db"
)
const WStoreDBName = "waveterm.db"
@@ -29,7 +32,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
}
@@ -52,6 +55,26 @@ func MakeDB(ctx context.Context) (*sqlx.DB, error) {
return rtn, nil
}
-func MigrateWStore() error {
- return nil
+func WithTx(ctx context.Context, fn func(tx *TxWrap) error) (rtnErr error) {
+ ContextUpdatesBeginTx(ctx)
+ defer func() {
+ if rtnErr != nil {
+ ContextUpdatesRollbackTx(ctx)
+ } else {
+ ContextUpdatesCommitTx(ctx)
+ }
+ }()
+ return txwrap.WithTx(ctx, globalDB, fn)
+}
+
+func WithTxRtn[RT any](ctx context.Context, fn func(tx *TxWrap) (RT, error)) (rtnVal RT, rtnErr error) {
+ ContextUpdatesBeginTx(ctx)
+ defer func() {
+ if rtnErr != nil {
+ ContextUpdatesRollbackTx(ctx)
+ } else {
+ ContextUpdatesCommitTx(ctx)
+ }
+ }()
+ return txwrap.WithTxRtn(ctx, globalDB, fn)
}
diff --git a/tsconfig.json b/tsconfig.json
index 5a3c5ec7..c58021d8 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -20,6 +20,7 @@
"@/store/*": ["frontend/app/store/*"],
"@/element/*": ["frontend/app/element/*"],
"@/bindings/*": ["frontend/bindings/github.com/wavetermdev/thenextwave/pkg/service/*"],
+ "@/gopkg/*": ["frontend/bindings/github.com/wavetermdev/thenextwave/pkg/*"],
}
}
}