Implement new routing with tanstack router and mobile app kickoff (#248)

* Init mobile app
* Minor fixes and improvements
* Improve assets loading
* Fix event bus proxy
* Improve emojis and icons loading
* Improve app initialization in web
* Init tanstack router
* Refactor components
* Refactor some more components
* Refactor layouts
* Improve routing
* Improve routing
* Routing improvements
* Make sidebar work in mobile
* Refactor container and breadcrumb
* Fix some packages and warnings
* Encode and decode yjs update for ipc communication
* Refactor servers in client
* Fix some errors and warnings in editor and sidebar
* Add route masking for web
* Improve container layout
* Improve ui for mobile
* Improve mobile ui
* Create custom link component
* Router improvements
* Implement tabs for desktop
* tabs improvements
* Refactor routes
* Layout improvements
* Improve desktop tabs
* Use tanstack-db for global collections
* Improve tanstack db collections
* Refactor workspaces and accounts databases and routes locally
* Use tanstackdb for users
* Use tanstackdb for uploads and downloads
* Use tanstackdb for temp files
* Rename database to collections
* Improve tabs
* Fix packages
* Improve local file handling
* Rename sync cursor keys
* Save some bootstrap data in a file
* Reset all data on new version update in desktop
* Minor refactor
* Implement app reset on startup
* UI fixes and improvements
* More Ui improvements
* Fix logout
* Add tab in route contexts for workspace routes
* Store last used workspace id as metadata
* Fix account logout
* Fix file preview
* Fix file thumbnail loading in tabs
* Fix chat tab loading
* Fix some redirect handling
* Disable staletime for icon.svg.get query
* Fix not found pages and throws
* add readme for mobile
* fix some keys
* Improve add tab handler
* Fix path
This commit is contained in:
Hakan Shehu
2025-11-11 07:00:14 -08:00
committed by GitHub
parent 316a388d4a
commit 3e4c8b8125
650 changed files with 18610 additions and 11315 deletions

22
.gitignore vendored
View File

@@ -146,6 +146,7 @@ dist/
.DS_Store
.turbo
.tsbuildinfo
*.pem
# Ignore temporary emoji and icon directories
src/scripts/emojis/temp/
@@ -154,8 +155,6 @@ src/scripts/icons/temp/
# Ignore desktop assets
apps/desktop/assets/emojis.db
apps/desktop/assets/icons.db
apps/desktop/assets/emojis.svg
apps/desktop/assets/icons.svg
apps/desktop/assets/fonts/neotrax.otf
apps/desktop/assets/colanode-logo.png
apps/desktop/assets/colanode-logo.ico
@@ -169,3 +168,22 @@ apps/web/public/assets/icons.svg
apps/web/public/assets/fonts/neotrax.otf
apps/web/public/assets/colanode-logo-192.jpg
apps/web/public/assets/colanode-logo-512.jpg
# Ignore mobile assets
apps/mobile/assets/ui/index.html
apps/mobile/assets/emojis.db
apps/mobile/assets/icons.db
apps/mobile/assets/fonts/neotrax.otf
.expo
web-build/
expo-env.d.ts
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
/ios
/android

View File

@@ -8,6 +8,8 @@ import {
dialog,
nativeTheme,
} from 'electron';
import fs from 'fs';
import path from 'path';
import started from 'electron-squirrel-startup';
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
@@ -15,16 +17,35 @@ import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
import { eventBus } from '@colanode/client/lib';
import { MutationInput, MutationMap } from '@colanode/client/mutations';
import { QueryInput, QueryMap } from '@colanode/client/queries';
import { TempFile } from '@colanode/client/types';
import { AppMeta, AppService } from '@colanode/client/services';
import { AppInitOutput, TempFile, ThemeMode } from '@colanode/client/types';
import {
build,
createDebugger,
extractFileSubtype,
generateId,
IdType,
} from '@colanode/core';
import { app, appBadge } from '@colanode/desktop/main/app-service';
import { AppBadge } from '@colanode/desktop/main/app-badge';
import { BootstrapService } from '@colanode/desktop/main/bootstrap';
import { DesktopFileSystem } from '@colanode/desktop/main/file-system';
import { DesktopKyselyService } from '@colanode/desktop/main/kysely-service';
import { DesktopPathService } from '@colanode/desktop/main/path-service';
import { handleLocalRequest } from '@colanode/desktop/main/protocols';
const appMeta: AppMeta = {
type: 'desktop',
platform: process.platform,
};
const fileSystem = new DesktopFileSystem();
const pathService = new DesktopPathService();
const kyselyService = new DesktopKyselyService();
const bootstrap = new BootstrapService(pathService);
let app: AppService | null = null;
let appBadge: AppBadge | null = null;
const debug = createDebugger('desktop:main');
electronApp.setName('Colanode');
@@ -46,62 +67,46 @@ updateElectronApp({
});
const createWindow = async () => {
await app.migrate();
const themeMode = (await app.metadata.get('theme.mode'))?.value;
if (themeMode) {
nativeTheme.themeSource = themeMode;
}
nativeTheme.themeSource = bootstrap.theme ?? 'system';
// Create the browser window.
let windowSize = (await app.metadata.get('window.size'))?.value;
const mainWindow = new BrowserWindow({
width: windowSize?.width ?? 1200,
height: windowSize?.height ?? 800,
fullscreen: windowSize?.fullscreen ?? false,
width: bootstrap.window.width,
height: bootstrap.window.height,
fullscreen: bootstrap.window.fullscreen,
x: bootstrap.window.x,
y: bootstrap.window.y,
fullscreenable: true,
minWidth: 800,
minHeight: 600,
icon: app.path.join(app.path.assets, 'colanode-logo.png'),
icon: path.join(pathService.assets, 'colanode-logo.png'),
webPreferences: {
preload: app.path.join(__dirname, 'preload.js'),
preload: path.join(__dirname, 'preload.js'),
},
autoHideMenuBar: true,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 5, y: 5 },
});
mainWindow.setMenuBarVisibility(false);
mainWindow.on('resized', () => {
windowSize = {
const updateWindowState = () => {
bootstrap.updateWindow({
fullscreen: mainWindow.isFullScreen(),
width: mainWindow.getBounds().width,
height: mainWindow.getBounds().height,
fullscreen: false,
};
x: mainWindow.getBounds().x,
y: mainWindow.getBounds().y,
});
app.metadata.set('window.size', windowSize);
});
if (app) {
app.metadata.set('app', 'window', bootstrap.window);
}
};
mainWindow.on('enter-full-screen', () => {
windowSize = {
width: windowSize?.width ?? mainWindow.getBounds().width,
height: windowSize?.height ?? mainWindow.getBounds().height,
fullscreen: true,
};
app.metadata.set('window.size', windowSize);
});
mainWindow.on('leave-full-screen', () => {
windowSize = {
width: windowSize?.width ?? mainWindow.getBounds().width,
height: windowSize?.height ?? mainWindow.getBounds().height,
fullscreen: false,
};
app.metadata.set('window.size', windowSize);
});
mainWindow.on('resized', updateWindowState);
mainWindow.on('enter-full-screen', updateWindowState);
mainWindow.on('leave-full-screen', updateWindowState);
mainWindow.on('moved', updateWindowState);
// and load the index.html of the app.
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
@@ -110,32 +115,35 @@ const createWindow = async () => {
// mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(
app.path.join(
__dirname,
`../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`
)
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`)
);
}
const subscriptionId = eventBus.subscribe((event) => {
if (event.type === 'query.result.updated') {
mainWindow.webContents.send('event', event);
} else if (
event.type === 'app.metadata.updated' &&
mainWindow.webContents.send('event', event);
if (
event.type === 'metadata.updated' &&
event.metadata.key === 'theme.mode'
) {
nativeTheme.themeSource = event.metadata.value;
const themeMode = JSON.parse(event.metadata.value) as ThemeMode;
nativeTheme.themeSource = themeMode;
bootstrap.updateTheme(themeMode);
} else if (
event.type === 'app.metadata.deleted' &&
event.type === 'metadata.deleted' &&
event.metadata.key === 'theme.mode'
) {
nativeTheme.themeSource = 'system';
bootstrap.updateTheme(null);
}
});
if (!protocol.isProtocolHandled('local')) {
protocol.handle('local', (request) => {
return handleLocalRequest(request);
if (!app) {
throw new Error('App is not initialized');
}
return handleLocalRequest(pathService, app?.assets, request);
});
}
@@ -156,6 +164,31 @@ const createWindow = async () => {
debug('Window created');
};
const initApp = async (): Promise<AppInitOutput> => {
if (bootstrap.needsFreshStart) {
return 'reset';
}
app = new AppService(appMeta, fileSystem, kyselyService, pathService);
appBadge = new AppBadge(app);
await app.init();
appBadge.init();
await bootstrap.updateVersion(build.version);
await app.metadata.set('app', 'version', bootstrap.version);
await app.metadata.set('app', 'platform', appMeta.platform);
await app.metadata.set('app', 'window', bootstrap.window);
if (bootstrap.theme) {
await app.metadata.set('app', 'theme.mode', bootstrap.theme);
} else {
await app.metadata.delete('app', 'theme.mode');
}
return 'success';
};
protocol.registerSchemesAsPrivileged([
{ scheme: 'local', privileges: { standard: true, stream: true } },
]);
@@ -173,7 +206,9 @@ electronApp.on('window-all-closed', () => {
electronApp.quit();
}
app.mediator.clearSubscriptions();
if (app) {
app.mediator.clearSubscriptions();
}
});
electronApp.on('activate', () => {
@@ -187,8 +222,13 @@ electronApp.on('activate', () => {
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and import them here.
ipcMain.handle('init', async () => {
await app.init();
appBadge.init();
return initApp();
});
ipcMain.handle('reset', async () => {
await fs.promises.rm(pathService.app, { recursive: true, force: true });
electronApp.relaunch();
electronApp.exit(0);
});
ipcMain.handle(
@@ -197,6 +237,10 @@ ipcMain.handle(
_: unknown,
input: T
): Promise<MutationMap[T['type']]['output']> => {
if (!app) {
throw new Error('App is not initialized');
}
return app.mediator.executeMutation(input);
}
);
@@ -207,6 +251,10 @@ ipcMain.handle(
_: unknown,
input: T
): Promise<QueryMap[T['type']]['output']> => {
if (!app) {
throw new Error('App is not initialized');
}
return app.mediator.executeQuery(input);
}
);
@@ -219,6 +267,10 @@ ipcMain.handle(
windowId: string,
input: T
): Promise<QueryMap[T['type']]['output']> => {
if (!app) {
throw new Error('App is not initialized');
}
return app.mediator.executeQueryAndSubscribe(key, windowId, input);
}
);
@@ -226,6 +278,10 @@ ipcMain.handle(
ipcMain.handle(
'unsubscribe-query',
(_: unknown, key: string, windowId: string): void => {
if (!app) {
throw new Error('App is not initialized');
}
app.mediator.unsubscribeQuery(key, windowId);
}
);
@@ -237,6 +293,10 @@ ipcMain.handle(
file: { name: string; size: number; type: string; buffer: Buffer }
): Promise<TempFile> => {
const id = generateId(IdType.TempFile);
if (!app) {
throw new Error('App is not initialized');
}
const extension = app.path.extension(file.name);
const mimeType = file.type;
const subtype = extractFileSubtype(mimeType);

View File

@@ -31,8 +31,8 @@ export class AppBadge {
return;
}
const accounts = this.app.getAccounts();
if (accounts.length === 0) {
const workspaces = this.app.getWorkspaces();
if (workspaces.length === 0) {
electronApp?.dock?.setBadge('');
return;
}
@@ -40,14 +40,10 @@ export class AppBadge {
let hasUnread = false;
let unreadCount = 0;
for (const account of accounts) {
const workspaces = account.getWorkspaces();
for (const workspace of workspaces) {
const radarData = workspace.radar.getData();
hasUnread = hasUnread || radarData.state.hasUnread;
unreadCount = unreadCount + radarData.state.unreadCount;
}
for (const workspace of workspaces) {
const radarData = workspace.radar.getData();
hasUnread = hasUnread || radarData.state.hasUnread;
unreadCount = unreadCount + radarData.state.unreadCount;
}
if (unreadCount > 0) {

View File

@@ -1,19 +0,0 @@
import { AppMeta, AppService } from '@colanode/client/services';
import { AppBadge } from '@colanode/desktop/main/app-badge';
import { DesktopFileSystem } from '@colanode/desktop/main/file-system';
import { DesktopKyselyService } from '@colanode/desktop/main/kysely-service';
import { DesktopPathService } from '@colanode/desktop/main/path-service';
const appMeta: AppMeta = {
type: 'desktop',
platform: process.platform,
};
export const app = new AppService(
appMeta,
new DesktopFileSystem(),
new DesktopKyselyService(),
new DesktopPathService()
);
export const appBadge = new AppBadge(app);

View File

@@ -0,0 +1,132 @@
import fs from 'fs';
import { ThemeMode, WindowState } from '@colanode/client/types';
import { build } from '@colanode/core';
import { DesktopPathService } from '@colanode/desktop/main/path-service';
interface BootstrapData {
version: string;
theme: ThemeMode | null;
window: WindowState;
}
export class BootstrapService {
private data: BootstrapData;
private readonly paths: DesktopPathService;
private readonly requiresFreshStart: boolean;
constructor(paths: DesktopPathService) {
this.paths = paths;
const bootstrapExists = fs.existsSync(this.paths.bootstrap);
const appDatabaseExists = fs.existsSync(this.paths.appDatabase);
this.requiresFreshStart = !bootstrapExists && appDatabaseExists;
this.data = this.load(bootstrapExists);
}
private load(bootstrapExists: boolean): BootstrapData {
try {
if (!bootstrapExists) {
return this.getDefaultData();
}
const content = fs.readFileSync(this.paths.bootstrap, 'utf-8');
const parsed = JSON.parse(content);
return {
version: parsed.version || build.version,
theme: parsed.theme || 'system',
window: {
fullscreen: parsed.window?.fullscreen || false,
width: parsed.window?.width || 1280,
height: parsed.window?.height || 800,
x: parsed.window?.x || 100,
y: parsed.window?.y || 100,
},
};
} catch {
return this.getDefaultData();
}
}
private getDefaultData(): BootstrapData {
return {
version: build.version,
theme: null,
window: {
fullscreen: false,
width: 1280,
height: 800,
x: 100,
y: 100,
},
};
}
public async save(): Promise<void> {
if (this.requiresFreshStart) {
return;
}
try {
await fs.promises.mkdir(this.paths.dirname(this.paths.bootstrap), {
recursive: true,
});
await fs.promises.writeFile(
this.paths.bootstrap,
JSON.stringify(this.data, null, 2)
);
} catch (error) {
console.error('Failed to save bootstrap data:', error);
}
}
public get version(): string {
return this.data.version;
}
public get theme(): ThemeMode | null {
return this.data.theme;
}
public get window(): WindowState {
return { ...this.data.window };
}
public async updateVersion(version: string): Promise<void> {
this.data.version = version;
await this.save();
}
public async updateWindowFullscreen(fullscreen: boolean): Promise<void> {
this.data.window.fullscreen = fullscreen;
await this.save();
}
public async updateWindowSize(width: number, height: number): Promise<void> {
this.data.window.width = width;
this.data.window.height = height;
await this.save();
}
public async updateWindowPosition(x: number, y: number): Promise<void> {
this.data.window.x = x;
this.data.window.y = y;
await this.save();
}
public async updateTheme(theme: ThemeMode | null): Promise<void> {
this.data.theme = theme;
await this.save();
}
public async updateWindow(state: WindowState): Promise<void> {
this.data.window = state;
await this.save();
}
public get needsFreshStart(): boolean {
return this.requiresFreshStart;
}
}

View File

@@ -5,48 +5,33 @@ import { PathService } from '@colanode/client/services';
export class DesktopPathService implements PathService {
private readonly nativePath = path;
private readonly appPath = app.getPath('userData');
private readonly bootstrapPath = this.nativePath.join(
this.appPath,
'bootstrap.json'
);
private readonly appDatabasePath = this.nativePath.join(
this.appPath,
'app.db'
);
private readonly accountsDirectoryPath = this.nativePath.join(
private readonly avatarsPath = this.nativePath.join(this.appPath, 'avatars');
private readonly workspacesPath = this.nativePath.join(
this.appPath,
'accounts'
'workspaces'
);
private getAccountDirectoryPath(accountId: string): string {
return this.nativePath.join(this.accountsDirectoryPath, accountId);
private getWorkspaceDirectoryPath(userId: string): string {
return this.nativePath.join(this.workspacesPath, userId);
}
private getWorkspaceDirectoryPath(
accountId: string,
workspaceId: string
): string {
private getWorkspaceFilesDirectoryPath(userId: string): string {
return this.nativePath.join(
this.getAccountDirectoryPath(accountId),
'workspaces',
workspaceId
);
}
private getWorkspaceFilesDirectoryPath(
accountId: string,
workspaceId: string
): string {
return this.nativePath.join(
this.getWorkspaceDirectoryPath(accountId, workspaceId),
this.getWorkspaceDirectoryPath(userId),
'files'
);
}
private getAccountAvatarsDirectoryPath(accountId: string): string {
return this.nativePath.join(
this.getAccountDirectoryPath(accountId),
'avatars'
);
}
private getAssetsSourcePath(): string {
if (app.isPackaged) {
return this.nativePath.join(process.resourcesPath, 'assets');
@@ -58,71 +43,56 @@ export class DesktopPathService implements PathService {
return this.appPath;
}
public get appDatabase(): string {
return this.appDatabasePath;
public get bootstrap(): string {
return this.bootstrapPath;
}
public get accounts(): string {
return this.accountsDirectoryPath;
public get appDatabase(): string {
return this.appDatabasePath;
}
public get temp(): string {
return this.nativePath.join(this.appPath, 'temp');
}
public get avatars(): string {
return this.avatarsPath;
}
public tempFile(name: string): string {
return this.nativePath.join(this.appPath, 'temp', name);
}
public account(accountId: string): string {
return this.getAccountDirectoryPath(accountId);
public avatar(avatarId: string): string {
return this.nativePath.join(this.avatarsPath, avatarId + '.jpeg');
}
public accountDatabase(accountId: string): string {
public workspace(userId: string): string {
return this.getWorkspaceDirectoryPath(userId);
}
public workspaceDatabase(userId: string): string {
return this.nativePath.join(
this.getAccountDirectoryPath(accountId),
'account.db'
);
}
public workspace(accountId: string, workspaceId: string): string {
return this.getWorkspaceDirectoryPath(accountId, workspaceId);
}
public workspaceDatabase(accountId: string, workspaceId: string): string {
return this.nativePath.join(
this.getWorkspaceDirectoryPath(accountId, workspaceId),
this.getWorkspaceDirectoryPath(userId),
'workspace.db'
);
}
public workspaceFiles(accountId: string, workspaceId: string): string {
return this.getWorkspaceFilesDirectoryPath(accountId, workspaceId);
public workspaceFiles(userId: string): string {
return this.getWorkspaceFilesDirectoryPath(userId);
}
public workspaceFile(
accountId: string,
workspaceId: string,
userId: string,
fileId: string,
extension: string
): string {
return this.nativePath.join(
this.getWorkspaceFilesDirectoryPath(accountId, workspaceId),
this.getWorkspaceFilesDirectoryPath(userId),
fileId + extension
);
}
public accountAvatars(accountId: string): string {
return this.getAccountAvatarsDirectoryPath(accountId);
}
public accountAvatar(accountId: string, avatarId: string): string {
return this.nativePath.join(
this.getAccountAvatarsDirectoryPath(accountId),
avatarId + '.jpeg'
);
}
public dirname(dir: string): string {
return this.nativePath.dirname(dir);
}
@@ -154,4 +124,8 @@ export class DesktopPathService implements PathService {
public get iconsDatabase(): string {
return this.nativePath.join(this.getAssetsSourcePath(), 'icons.db');
}
public font(name: string): string {
return this.nativePath.join(this.getAssetsSourcePath(), 'fonts', name);
}
}

View File

@@ -1,9 +1,11 @@
import { net } from 'electron';
import path from 'path';
import { app } from '@colanode/desktop/main/app-service';
import { AssetService, PathService } from '@colanode/client/services';
export const handleLocalRequest = async (
paths: PathService,
assets: AssetService | null,
request: Request
): Promise<Response> => {
const url = request.url.replace('local://', '');
@@ -20,14 +22,18 @@ export const handleLocalRequest = async (
return new Response(null, { status: 400 });
}
const emoji = await app.assets.emojis
if (!assets) {
return new Response(null, { status: 400 });
}
const emoji = await assets.emojis
.selectFrom('emoji_svgs')
.selectAll()
.where('skin_id', '=', skinId)
.executeTakeFirst();
if (emoji) {
return new Response(emoji.svg, {
return new Response(emoji.svg.toString('utf-8'), {
headers: {
'Content-Type': 'image/svg+xml',
},
@@ -41,14 +47,18 @@ export const handleLocalRequest = async (
return new Response(null, { status: 400 });
}
const icon = await app.assets.icons
if (!assets) {
return new Response(null, { status: 400 });
}
const icon = await assets.icons
.selectFrom('icon_svgs')
.selectAll()
.where('id', '=', iconId)
.executeTakeFirst();
if (icon) {
return new Response(icon.svg, {
return new Response(icon.svg.toString('utf-8'), {
headers: {
'Content-Type': 'image/svg+xml',
},
@@ -62,7 +72,7 @@ export const handleLocalRequest = async (
return new Response(null, { status: 400 });
}
const filePath = path.join(app.path.assets, 'fonts', fontName);
const filePath = path.join(paths.assets, 'fonts', fontName);
const fileUrl = `file://${filePath}`;
const subRequest = new Request(fileUrl, request);
return net.fetch(subRequest);

View File

@@ -13,6 +13,7 @@ const windowId = generateId(IdType.Window);
contextBridge.exposeInMainWorld('colanode', {
init: () => ipcRenderer.invoke('init'),
reset: () => ipcRenderer.invoke('reset'),
executeMutation: <T extends MutationInput>(
input: T

View File

@@ -3,10 +3,10 @@ import '../../../packages/ui/src/styles/globals.css';
import { createRoot } from 'react-dom/client';
import { RootProvider } from '@colanode/ui';
import { App } from '@colanode/ui';
const Root = () => {
return <RootProvider type="desktop" />;
return <App type="desktop" />;
};
const root = createRoot(document.getElementById('root') as HTMLElement);

5
apps/mobile/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Colanode Mobile
> **Status:** Experimental work in progress
> The Colanode mobile app is under active development and **not ready for production use**.
> It is included in this repository to make it easier to test, iterate, and contribute to its development.

31
apps/mobile/app.json Normal file
View File

@@ -0,0 +1,31 @@
{
"expo": {
"name": "Colanode",
"slug": "colanode",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": ["expo-asset", "expo-sqlite"]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
apps/mobile/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

12
apps/mobile/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Colanode</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="src/ui/main.tsx"></script>
</body>
</html>

44
apps/mobile/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@colanode/mobile",
"version": "1.0.0",
"author": "Colanode",
"description": "Colanode mobile application",
"main": "src/index.ts",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"dev:ui": "vite",
"build:ui": "vite build"
},
"dependencies": {
"expo": "~54.0.10",
"expo-asset": "~12.0.9",
"expo-crypto": "~15.0.7",
"expo-device": "~8.0.8",
"expo-file-system": "~19.0.15",
"expo-random": "^14.0.1",
"expo-sqlite": "~16.0.8",
"expo-status-bar": "~3.0.8",
"isomorphic-webcrypto": "^2.3.8",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.4",
"react-native-get-random-values": "~1.11.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-webview": "13.15.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.13",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"babel-preset-expo": "^54.0.1",
"typescript": "~5.9.2",
"vite": "^7.1.5",
"vite-plugin-singlefile": "^2.3.0"
},
"private": true
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

192
apps/mobile/src/app.tsx Normal file
View File

@@ -0,0 +1,192 @@
import { Asset } from 'expo-asset';
import { modelName } from 'expo-device';
import { useCallback, useEffect, useRef, useState } from 'react';
import { View, ActivityIndicator, Platform, StyleSheet } from 'react-native';
import { SafeAreaView, SafeAreaProvider } from 'react-native-safe-area-context';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import { eventBus } from '@colanode/client/lib';
import { AppMeta, AppService } from '@colanode/client/services';
import { generateId, IdType } from '@colanode/core';
import { copyAssets, indexHtmlAsset } from '@colanode/mobile/lib/assets';
import { Message } from '@colanode/mobile/lib/types';
import { MobileFileSystem } from '@colanode/mobile/services/file-system';
import { MobileKyselyService } from '@colanode/mobile/services/kysely-service';
import { MobilePathService } from '@colanode/mobile/services/path-service';
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0a0a0a', padding: 0, margin: 0 },
});
export const App = () => {
const windowId = useRef<string>(generateId(IdType.Window));
const webViewRef = useRef<WebView>(null);
const app = useRef<AppService | null>(null);
const appInitialized = useRef<boolean>(false);
const [uri, setUri] = useState<string | null>(null);
const [baseDir, setBaseDir] = useState<string | null>(null);
useEffect(() => {
(async () => {
const indexAsset = Asset.fromModule(indexHtmlAsset);
await indexAsset.downloadAsync();
const localUri = indexAsset.localUri ?? indexAsset.uri;
const dir = localUri.replace(/index\.html$/, '');
setUri(localUri);
setBaseDir(dir);
})();
}, []);
useEffect(() => {
(async () => {
try {
const paths = new MobilePathService();
await copyAssets(paths);
const appMeta: AppMeta = {
type: 'mobile',
platform: modelName ?? 'unknown',
};
app.current = new AppService(
appMeta,
new MobileFileSystem(),
new MobileKyselyService(),
paths
);
await app.current.migrate();
await app.current.init();
appInitialized.current = true;
} catch (error) {
console.error(error);
}
})();
}, []);
useEffect(() => {
const id = eventBus.subscribe((event) => {
sendMessage({ type: 'event', windowId: windowId.current, event });
});
return () => eventBus.unsubscribe(id);
}, []);
const handleMessage = useCallback(async (e: WebViewMessageEvent) => {
const message = JSON.parse(e.nativeEvent.data) as Message;
if (message.type === 'console') {
if (message.level === 'log') {
console.log(
`[WebView ${message.level.toUpperCase()}] ${message.timestamp} ${message.message}`
);
} else if (message.level === 'warn') {
console.warn(
`[WebView ${message.level.toUpperCase()}] ${message.timestamp} ${message.message}`
);
} else if (message.level === 'error') {
console.error(
`[WebView ${message.level.toUpperCase()}] ${message.timestamp} ${message.message}`
);
} else if (message.level === 'info') {
console.info(
`[WebView ${message.level.toUpperCase()}] ${message.timestamp} ${message.message}`
);
} else if (message.level === 'debug') {
console.debug(
`[WebView ${message.level.toUpperCase()}] ${message.timestamp} ${message.message}`
);
}
} else if (message.type === 'init') {
let count = 0;
while (!appInitialized.current) {
await new Promise((resolve) => setTimeout(resolve, 50));
count++;
if (count > 100) {
throw new Error('App initialization timed out');
}
}
sendMessage({ type: 'init_result' });
} else if (message.type === 'mutation') {
if (!app.current) {
return;
}
const result = await app.current.mediator.executeMutation(message.input);
sendMessage({
type: 'mutation_result',
mutationId: message.mutationId,
result,
});
} else if (message.type === 'query') {
if (!app.current) {
return;
}
const result = await app.current.mediator.executeQuery(message.input);
sendMessage({ type: 'query_result', queryId: message.queryId, result });
} else if (message.type === 'query_and_subscribe') {
if (!app.current) {
return;
}
const result = await app.current.mediator.executeQueryAndSubscribe(
message.key,
message.windowId,
message.input
);
sendMessage({
type: 'query_and_subscribe_result',
queryId: message.queryId,
key: message.key,
windowId: message.windowId,
result,
});
} else if (message.type === 'query_unsubscribe') {
if (!app.current) {
return;
}
app.current.mediator.unsubscribeQuery(message.key, message.windowId);
} else if (message.type === 'event') {
eventBus.publish(message.event);
}
}, []);
const sendMessage = useCallback((message: Message) => {
webViewRef.current?.postMessage(JSON.stringify(message));
}, []);
if (!uri) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator />
</View>
);
}
return (
<SafeAreaProvider>
<SafeAreaView
edges={['top', 'bottom', 'left', 'right']}
style={styles.container}
>
<WebView
ref={webViewRef}
style={{ flex: 1, padding: 0, margin: 0, backgroundColor: '#0a0a0a' }}
originWhitelist={['*']}
allowFileAccess
allowFileAccessFromFileURLs
allowingReadAccessToURL={
Platform.OS === 'ios' ? (baseDir ?? uri) : undefined
}
source={{ uri }}
javaScriptEnabled
setSupportMultipleWindows={false}
onMessage={handleMessage}
allowsBackForwardNavigationGestures={true}
/>
</SafeAreaView>
</SafeAreaProvider>
);
};

9
apps/mobile/src/index.ts Normal file
View File

@@ -0,0 +1,9 @@
import 'react-native-get-random-values';
import { registerRootComponent } from 'expo';
import { App } from './app';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

View File

@@ -0,0 +1,51 @@
import { Asset } from 'expo-asset';
import { Directory, File } from 'expo-file-system';
import { PathService } from '@colanode/client/services';
import emojisDatabaseAsset from '../../assets/emojis.db';
import neotraxFontAsset from '../../assets/fonts/neotrax.otf';
import iconsDatabaseAsset from '../../assets/icons.db';
import indexHtmlAsset from '../../assets/ui/index.html';
export {
indexHtmlAsset,
emojisDatabaseAsset,
iconsDatabaseAsset,
neotraxFontAsset,
};
export const copyAssets = async (paths: PathService) => {
try {
const assetsDir = new Directory(paths.assets);
assetsDir.create({ intermediates: true, idempotent: true });
const fontsDir = new Directory(paths.fonts);
fontsDir.create({ intermediates: true, idempotent: true });
await copyAsset(
Asset.fromModule(emojisDatabaseAsset),
paths.emojisDatabase
);
await copyAsset(Asset.fromModule(iconsDatabaseAsset), paths.iconsDatabase);
await copyAsset(
Asset.fromModule(neotraxFontAsset),
paths.font('neotrax.otf')
);
} catch (error) {
console.error(error);
}
};
export const copyAsset = async (asset: Asset, path: string) => {
await asset.downloadAsync();
const localUri = asset.localUri ?? asset.uri;
const dest = new File(path);
if (dest.exists) {
dest.delete();
}
const assetFile = new File(localUri);
assetFile.copy(dest);
};

View File

@@ -0,0 +1,129 @@
import { MutationInput, MutationResult } from '@colanode/client/mutations';
import { QueryInput, QueryMap } from '@colanode/client/queries';
import { Event } from '@colanode/client/types';
declare global {
interface Window {
ReactNativeWebView: {
postMessage: (message: string) => void;
};
}
}
export type InitMessage = {
type: 'init';
};
export type InitResultMessage = {
type: 'init_result';
};
export type MutationMessage = {
type: 'mutation';
mutationId: string;
input: MutationInput;
};
export type MutationResultMessage = {
type: 'mutation_result';
mutationId: string;
result: MutationResult<MutationInput>;
};
export type QueryMessage = {
type: 'query';
queryId: string;
input: QueryInput;
};
export type QueryResultMessage = {
type: 'query_result';
queryId: string;
result: QueryMap[QueryInput['type']]['output'];
};
export type QueryAndSubscribeMessage = {
type: 'query_and_subscribe';
queryId: string;
key: string;
windowId: string;
input: QueryInput;
};
export type QueryAndSubscribeResultMessage = {
type: 'query_and_subscribe_result';
key: string;
windowId: string;
queryId: string;
result: QueryMap[QueryInput['type']]['output'];
};
export type QueryUnsubscribeMessage = {
type: 'query_unsubscribe';
key: string;
windowId: string;
};
export type EventMessage = {
type: 'event';
windowId: string;
event: Event;
};
export type ConsoleMessage = {
type: 'console';
level: 'log' | 'warn' | 'error' | 'info' | 'debug';
message: string;
timestamp: string;
};
export type Message =
| InitMessage
| InitResultMessage
| MutationMessage
| MutationResultMessage
| QueryMessage
| QueryResultMessage
| QueryAndSubscribeMessage
| QueryAndSubscribeResultMessage
| QueryUnsubscribeMessage
| EventMessage
| ConsoleMessage;
export type PendingInit = {
type: 'init';
resolve: () => void;
reject: (error: string) => void;
};
export type PendingQuery = {
type: 'query';
queryId: string;
input: QueryInput;
resolve: (result: QueryMap[QueryInput['type']]['output']) => void;
reject: (error: string) => void;
};
export type PendingQueryAndSubscribe = {
type: 'query_and_subscribe';
queryId: string;
key: string;
windowId: string;
input: QueryInput;
resolve: (result: QueryMap[QueryInput['type']]['output']) => void;
reject: (error: string) => void;
};
export type PendingMutation = {
type: 'mutation';
mutationId: string;
input: MutationInput;
resolve: (result: MutationResult<MutationInput>) => void;
reject: (error: string) => void;
};
export type PendingPromise =
| PendingInit
| PendingQuery
| PendingQueryAndSubscribe
| PendingMutation;

View File

@@ -0,0 +1,137 @@
import { Directory, File as ExpoFile, Paths } from 'expo-file-system';
import { FileReadStream, FileSystem } from '@colanode/client/services';
export class MobileFileSystem implements FileSystem {
private resolveDirectory(path: string): Directory | null {
const directoryUri = Paths.dirname(path);
if (!directoryUri || directoryUri === path) {
return null;
}
return new Directory(directoryUri);
}
private async ensureDirectory(path: string): Promise<void> {
const directory = new Directory(path);
if (!directory.exists) {
directory.create({ intermediates: true, idempotent: true });
}
}
private async ensureParentDirectory(path: string): Promise<void> {
const parentDirectory = this.resolveDirectory(path);
if (!parentDirectory) {
return;
}
await this.ensureDirectory(parentDirectory.uri);
}
public async makeDirectory(path: string): Promise<void> {
await this.ensureDirectory(path);
}
public async exists(path: string): Promise<boolean> {
try {
return Paths.info(path).exists;
} catch {
return false;
}
}
public async copy(source: string, destination: string): Promise<void> {
await this.ensureParentDirectory(destination);
const sourceFile = new ExpoFile(source);
const destinationFile = new ExpoFile(destination);
if (destinationFile.exists) {
destinationFile.delete();
}
if (!sourceFile.exists) {
throw new Error(`File not found: ${source}`);
}
sourceFile.copy(destinationFile);
}
public async readStream(path: string): Promise<FileReadStream> {
const file = new ExpoFile(path);
if (!file.exists) {
throw new Error(`File not found: ${path}`);
}
return file as unknown as FileReadStream;
}
public async writeStream(path: string): Promise<WritableStream<Uint8Array>> {
await this.ensureParentDirectory(path);
const file = new ExpoFile(path);
file.create({ intermediates: true, overwrite: true });
return file.writableStream();
}
public async listFiles(path: string): Promise<string[]> {
const directory = new Directory(path);
if (!directory.exists) {
return [];
}
return directory.list().map((entry) => entry.name);
}
public async readFile(path: string): Promise<Uint8Array> {
const file = new ExpoFile(path);
if (!file.exists) {
throw new Error(`File not found: ${path}`);
}
const bytes = await file.bytes();
return bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
}
public async writeFile(path: string, data: Uint8Array): Promise<void> {
await this.ensureParentDirectory(path);
const file = new ExpoFile(path);
file.create({ intermediates: true, overwrite: true });
file.write(data);
}
public async delete(path: string): Promise<void> {
const pathInfo = Paths.info(path);
if (!pathInfo.exists) {
return;
}
if (pathInfo.isDirectory) {
const directory = new Directory(path);
directory.delete();
return;
}
const file = new ExpoFile(path);
file.delete();
}
public async url(path: string): Promise<string> {
const file = new ExpoFile(path);
if (!file.exists) {
throw new Error(`File not found: ${path}`);
}
return file.uri;
}
}

View File

@@ -0,0 +1,167 @@
import {
SQLiteBindValue,
SQLiteDatabase,
SQLiteStatement,
openDatabaseSync,
} from 'expo-sqlite';
import {
CompiledQuery,
type DatabaseConnection,
type Dialect,
type Driver,
Kysely,
type QueryResult,
SqliteAdapter,
SqliteIntrospector,
SqliteQueryCompiler,
} from 'kysely';
import { KyselyBuildOptions, KyselyService } from '@colanode/client/services';
import { MobileFileSystem } from '@colanode/mobile/services/file-system';
import { MobilePathService } from '@colanode/mobile/services/path-service';
export class MobileKyselyService implements KyselyService {
private readonly fs = new MobileFileSystem();
public build<T>(options: KyselyBuildOptions): Kysely<T> {
const dialect = new SqliteExpoDialect(options);
return new Kysely<T>({
dialect,
});
}
public async delete(path: string): Promise<void> {
await this.fs.delete(path);
}
}
export class SqliteExpoDialect implements Dialect {
private readonly options: KyselyBuildOptions;
constructor(options: KyselyBuildOptions) {
this.options = options;
}
createAdapter = () => new SqliteAdapter();
createDriver = () => new ExpoSqliteDriver(this.options);
createIntrospector = (db: Kysely<unknown>) => new SqliteIntrospector(db);
createQueryCompiler = () => new SqliteQueryCompiler();
}
class ExpoSqliteDriver implements Driver {
private readonly connectionMutex = new ConnectionMutex();
private readonly connection: ExpoSqliteConnection;
constructor(options: KyselyBuildOptions) {
this.connection = new ExpoSqliteConnection(options);
}
async releaseConnection(): Promise<void> {
this.connectionMutex.unlock();
}
async init(): Promise<void> {}
async acquireConnection(): Promise<ExpoSqliteConnection> {
await this.connectionMutex.lock();
return this.connection;
}
async beginTransaction(connection: ExpoSqliteConnection): Promise<void> {
await connection.executeQuery(CompiledQuery.raw('begin'));
}
async commitTransaction(connection: ExpoSqliteConnection): Promise<void> {
await connection.executeQuery(CompiledQuery.raw('commit'));
}
async rollbackTransaction(connection: ExpoSqliteConnection): Promise<void> {
await connection.executeQuery(CompiledQuery.raw('rollback'));
}
async destroy(): Promise<void> {
await this.connection.closeConnection();
}
}
class ExpoSqliteConnection implements DatabaseConnection {
private readonly database: SQLiteDatabase;
private readonly options: KyselyBuildOptions;
private readonly paths: MobilePathService = new MobilePathService();
constructor(options: KyselyBuildOptions) {
const databaseName = this.paths.filename(options.path);
const databaseDirectory = this.paths.dirname(options.path);
this.database = openDatabaseSync(
databaseName,
undefined,
databaseDirectory
);
this.options = options;
}
async closeConnection(): Promise<void> {
return this.database.closeAsync();
}
async executeQuery<R>(compiledQuery: CompiledQuery): Promise<QueryResult<R>> {
const sql = compiledQuery.sql;
const parameters = compiledQuery.parameters as SQLiteBindValue[];
let statement: SQLiteStatement | undefined;
try {
statement = await this.database.prepareAsync(sql);
const result = await statement.executeAsync<R>(parameters);
const rows = await result.getAllAsync();
return {
rows,
insertId: BigInt(result.lastInsertRowId),
numAffectedRows: BigInt(result.changes),
numChangedRows: BigInt(result.changes),
};
} catch (error) {
console.error('error', error);
console.log('sql', sql);
console.log('parameters', parameters);
return {
rows: [],
insertId: BigInt(0),
numAffectedRows: BigInt(0),
numChangedRows: BigInt(0),
};
} finally {
await statement?.finalizeAsync();
}
}
streamQuery<R>(): AsyncIterableIterator<QueryResult<R>> {
throw new Error(
'Expo SQLite driver does not support iterate on prepared statements'
);
}
}
class ConnectionMutex {
private promise?: Promise<void>;
private resolve?: () => void;
async lock(): Promise<void> {
while (this.promise) {
await this.promise;
}
this.promise = new Promise((resolve) => {
this.resolve = resolve;
});
}
unlock(): void {
const resolve = this.resolve;
this.promise = undefined;
this.resolve = undefined;
resolve?.();
}
}

View File

@@ -0,0 +1,114 @@
import { Paths, File, Directory } from 'expo-file-system';
import { PathService } from '@colanode/client/services';
export class MobilePathService implements PathService {
private readonly avatarsDirectoryPath = new Directory(
Paths.document,
'avatars'
);
private readonly workspacesDirectoryPath = new Directory(
Paths.document,
'workspaces'
);
private getWorkspaceDirectoryPath(userId: string): string {
return new Directory(this.workspacesDirectoryPath, userId).uri;
}
private getWorkspaceFilesDirectoryPath(userId: string): string {
return new Directory(this.getWorkspaceDirectoryPath(userId), 'files').uri;
}
private getAssetsSourcePath(): string {
return new Directory(Paths.document, 'assets').uri;
}
public get app(): string {
return Paths.document.uri;
}
public get appDatabase(): string {
return new File(Paths.document, 'app.db').uri;
}
public get avatars(): string {
return this.avatarsDirectoryPath.uri;
}
public get temp(): string {
return new Directory(Paths.document, 'temp').uri;
}
public avatar(avatarId: string): string {
return new File(this.avatarsDirectoryPath, avatarId + '.jpeg').uri;
}
public tempFile(name: string): string {
return new File(Paths.document, 'temp', name).uri;
}
public workspace(userId: string): string {
return this.getWorkspaceDirectoryPath(userId);
}
public workspaceDatabase(userId: string): string {
return new File(this.getWorkspaceDirectoryPath(userId), 'workspace.db').uri;
}
public workspaceFiles(userId: string): string {
return this.getWorkspaceFilesDirectoryPath(userId);
}
public workspaceFile(
userId: string,
fileId: string,
extension: string
): string {
return new File(
this.getWorkspaceFilesDirectoryPath(userId),
fileId + extension
).uri;
}
public dirname(path: string): string {
const info = Paths.info(path);
if (info.isDirectory) {
return path;
}
const file = new File(path);
return file.parentDirectory.uri;
}
public filename(path: string): string {
const file = new File(path);
return file.name;
}
public extension(name: string): string {
const file = new File(name);
return file.extension;
}
public get assets(): string {
return this.getAssetsSourcePath();
}
public get fonts(): string {
return new Directory(this.getAssetsSourcePath(), 'fonts').uri;
}
public get emojisDatabase(): string {
return new File(this.getAssetsSourcePath(), 'emojis.db').uri;
}
public get iconsDatabase(): string {
return new File(this.getAssetsSourcePath(), 'icons.db').uri;
}
public font(name: string): string {
return new File(this.getAssetsSourcePath(), 'fonts', name).uri;
}
}

257
apps/mobile/src/ui/main.tsx Normal file
View File

@@ -0,0 +1,257 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { eventBus } from '@colanode/client/lib';
import { MutationInput, MutationResult } from '@colanode/client/mutations';
import { QueryInput, QueryMap } from '@colanode/client/queries';
import { generateId, IdType } from '@colanode/core/lib/id';
import {
InitMessage,
Message,
MutationMessage,
PendingPromise,
QueryAndSubscribeMessage,
QueryMessage,
QueryUnsubscribeMessage,
} from '@colanode/mobile/lib/types';
import { Root } from '@colanode/mobile/ui/root';
const windowId = generateId(IdType.Window);
const pendingPromises = new Map<string, PendingPromise>();
const postMessage = (message: Message) => {
window.ReactNativeWebView?.postMessage(JSON.stringify(message));
};
window.colanode = {
init: async () => {
const message: InitMessage = {
type: 'init',
};
const promise = new Promise<void>((resolve, reject) => {
pendingPromises.set('init', {
type: 'init',
resolve,
reject,
});
});
postMessage(message);
return promise;
},
executeMutation: async (input) => {
const mutationId = generateId(IdType.Mutation);
const message: MutationMessage = {
type: 'mutation',
mutationId,
input,
};
const promise = new Promise<MutationResult<MutationInput>>(
(resolve, reject) => {
pendingPromises.set(mutationId, {
type: 'mutation',
mutationId,
input,
resolve,
reject,
});
}
);
postMessage(message);
return promise;
},
executeQuery: async (input) => {
const queryId = generateId(IdType.Query);
const message: QueryMessage = {
type: 'query',
queryId,
input,
};
const promise = new Promise<QueryMap[QueryInput['type']]['output']>(
(resolve, reject) => {
pendingPromises.set(queryId, {
type: 'query',
queryId,
input,
resolve,
reject,
});
}
);
postMessage(message);
return promise;
},
executeQueryAndSubscribe: async (key, input) => {
const queryId = generateId(IdType.Query);
const message: QueryAndSubscribeMessage = {
type: 'query_and_subscribe',
queryId,
key,
windowId,
input,
};
const promise = new Promise<QueryMap[QueryInput['type']]['output']>(
(resolve, reject) => {
pendingPromises.set(queryId, {
type: 'query_and_subscribe',
queryId,
key,
windowId,
input,
resolve,
reject,
});
}
);
postMessage(message);
return promise;
},
saveTempFile: async () => {
throw new Error('Not implemented');
},
unsubscribeQuery: async (key) => {
const message: QueryUnsubscribeMessage = {
type: 'query_unsubscribe',
key,
windowId,
};
postMessage(message);
return Promise.resolve();
},
openExternalUrl: async (url) => {
window.open(url, '_blank');
},
showItemInFolder: async () => {
// No-op on web
},
showFileSaveDialog: async () => undefined,
};
const handleMessage = (message: Message) => {
if (message.type === 'init_result') {
const promise = pendingPromises.get('init');
if (!promise || promise.type !== 'init') {
return;
}
promise.resolve();
pendingPromises.delete('init');
} else if (message.type === 'mutation_result') {
const promise = pendingPromises.get(message.mutationId);
if (!promise || promise.type !== 'mutation') {
return;
}
promise.resolve(message.result);
pendingPromises.delete(message.mutationId);
} else if (message.type === 'query_result') {
const promise = pendingPromises.get(message.queryId);
if (!promise || promise.type !== 'query') {
return;
}
promise.resolve(message.result);
pendingPromises.delete(message.queryId);
} else if (message.type === 'query_and_subscribe_result') {
const promise = pendingPromises.get(message.queryId);
if (!promise || promise.type !== 'query_and_subscribe') {
return;
}
promise.resolve(message.result);
pendingPromises.delete(message.queryId);
} else if (message.type === 'event') {
eventBus.publish(message.event);
}
};
window.addEventListener('message', (event) => {
const message = JSON.parse(event.data) as Message;
handleMessage(message);
});
window.eventBus = eventBus;
const originalLog = console.log;
const originalWarn = console.warn;
const originalError = console.error;
const originalInfo = console.info;
const originalDebug = console.debug;
console.log = (...args) => {
originalLog(...args);
postMessage({
type: 'console',
level: 'log',
message: args.join(' '),
timestamp: new Date().toISOString(),
});
};
console.warn = (...args) => {
originalWarn(...args);
postMessage({
type: 'console',
level: 'warn',
message: args.join(' '),
timestamp: new Date().toISOString(),
});
};
console.error = (...args) => {
originalError(...args);
postMessage({
type: 'console',
level: 'error',
message: args.join(' '),
timestamp: new Date().toISOString(),
});
};
console.info = (...args) => {
originalInfo(...args);
postMessage({
type: 'console',
level: 'info',
message: args.join(' '),
timestamp: new Date().toISOString(),
});
};
console.debug = (...args) => {
originalDebug(...args);
postMessage({
type: 'console',
level: 'debug',
message: args.join(' '),
timestamp: new Date().toISOString(),
});
};
window.addEventListener('error', (event) => {
postMessage({
type: 'console',
level: 'error',
message: event.message,
timestamp: new Date().toISOString(),
});
});
window.addEventListener('unhandledrejection', (event) => {
postMessage({
type: 'console',
level: 'error',
message: event.reason,
timestamp: new Date().toISOString(),
});
});
ReactDOM.createRoot(document.getElementById('root')!).render(<Root />);

View File

@@ -0,0 +1,8 @@
// A workaround to make the globals.css file work in the mobile app
import '../../../../packages/ui/src/styles/globals.css';
import { App } from '@colanode/ui';
export const Root = () => {
return <App type="mobile" />;
};

31
apps/mobile/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"strict": true,
"target": "ES2020",
"lib": ["ES2020", "DOM"],
"module": "esnext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"jsx": "react-native",
"skipLibCheck": true,
"resolveJsonModule": true,
"paths": {
"@assets": ["./assets"],
"@colanode/mobile/*": ["./src/*"],
"@colanode/core/*": ["../../packages/core/src/*"],
"@colanode/crdt/*": ["../../packages/crdt/src/*"],
"@colanode/client/*": ["../../packages/client/src/*"],
"@colanode/ui/*": ["../../packages/ui/src/*"]
}
},
"exclude": ["node_modules"],
"extends": "expo/tsconfig.base",
"include": ["**/*", "assets/ui/index.html"],
"references": [
{ "path": "../../packages/core/tsconfig.json" },
{ "path": "../../packages/crdt/tsconfig.json" },
{ "path": "../../packages/client/tsconfig.json" },
{ "path": "../../packages/ui/tsconfig.json" }
],
}

View File

@@ -0,0 +1,30 @@
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { viteSingleFile } from 'vite-plugin-singlefile';
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
root: resolve(__dirname), // the 'ui/' folder
plugins: [react(), viteSingleFile()],
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
alias: {
'@assets': resolve(__dirname, './assets'),
'@colanode/mobile': resolve(__dirname, './src'),
'@colanode/core': resolve(__dirname, '../../packages/core/src'),
'@colanode/crdt': resolve(__dirname, '../../packages/crdt/src'),
'@colanode/client': resolve(__dirname, '../../packages/client/src'),
'@colanode/ui': resolve(__dirname, '../../packages/ui/src'),
},
},
build: {
outDir: resolve(__dirname, 'assets/ui'),
emptyOutDir: true,
assetsInlineLimit: 100000000, // inline assets
sourcemap: false,
},
});

View File

@@ -155,7 +155,7 @@ export class SocketConnection {
message.input,
cursor
);
} else if (message.input.type === 'nodes.updates') {
} else if (message.input.type === 'node.updates') {
if (!user.rootIds.has(message.input.rootId)) {
return null;
}

View File

@@ -1,6 +1,6 @@
import {
SynchronizerOutputMessage,
SyncNodesUpdatesInput,
SyncNodeUpdatesInput,
SyncNodeUpdateData,
} from '@colanode/core';
import { encodeState } from '@colanode/crdt';
@@ -12,8 +12,8 @@ import { Event } from '@colanode/server/types/events';
const logger = createLogger('node-update-synchronizer');
export class NodeUpdatesSynchronizer extends BaseSynchronizer<SyncNodesUpdatesInput> {
public async fetchData(): Promise<SynchronizerOutputMessage<SyncNodesUpdatesInput> | null> {
export class NodeUpdatesSynchronizer extends BaseSynchronizer<SyncNodeUpdatesInput> {
public async fetchData(): Promise<SynchronizerOutputMessage<SyncNodeUpdatesInput> | null> {
const nodeUpdates = await this.fetchNodeUpdates();
if (nodeUpdates.length === 0) {
return null;
@@ -24,7 +24,7 @@ export class NodeUpdatesSynchronizer extends BaseSynchronizer<SyncNodesUpdatesIn
public async fetchDataFromEvent(
event: Event
): Promise<SynchronizerOutputMessage<SyncNodesUpdatesInput> | null> {
): Promise<SynchronizerOutputMessage<SyncNodeUpdatesInput> | null> {
if (!this.shouldFetch(event)) {
return null;
}
@@ -66,7 +66,7 @@ export class NodeUpdatesSynchronizer extends BaseSynchronizer<SyncNodesUpdatesIn
private buildMessage(
unsyncedNodeUpdates: SelectNodeUpdate[]
): SynchronizerOutputMessage<SyncNodesUpdatesInput> {
): SynchronizerOutputMessage<SyncNodeUpdatesInput> {
const items: SyncNodeUpdateData[] = unsyncedNodeUpdates.map(
(nodeUpdate) => {
return {

View File

@@ -3,25 +3,25 @@ import { MonitorOff } from 'lucide-react';
export const BrowserNotSupported = () => {
return (
<div className="min-w-screen flex h-full min-h-screen w-full items-center justify-center">
<div className="flex flex-col items-center gap-8 text-center w-128">
<MonitorOff className="h-10 w-10 text-gray-800" />
<h2 className="text-4xl text-gray-800">Browser not supported</h2>
<p className="text-sm text-gray-500">
<div className="flex flex-col items-center gap-8 text-center w-lg">
<MonitorOff className="h-10 w-10 text-foreground" />
<h2 className="text-4xl text-foreground">Browser not supported</h2>
<p className="text-sm text-muted-foreground">
Unfortunately, your browser does not support the Origin Private File
System (OPFS) feature that Colanode requires to function properly.
</p>
<p className="text-sm text-gray-500">
<p className="text-sm text-muted-foreground">
If you're self-hosting Colanode make sure you are accessing the web
version through a secure 'https' way, because some browsers require
HTTPS to use the features required.
</p>
<p className="text-sm text-gray-500 mt-4">
<p className="text-sm text-muted-foreground mt-4">
You can try using the{' '}
<a
href="https://colanode.com/downloads"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500"
className="text-primary"
>
Desktop app
</a>{' '}
@@ -31,7 +31,7 @@ export const BrowserNotSupported = () => {
href="https://github.com/colanode/colanode"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500"
className="text-primary"
>
Github
</a>

View File

@@ -3,18 +3,18 @@ import { Smartphone } from 'lucide-react';
export const MobileNotSupported = () => {
return (
<div className="min-w-screen flex h-full min-h-screen w-full items-center justify-center">
<div className="flex flex-col items-center gap-8 text-center w-128">
<Smartphone className="h-10 w-10 text-gray-800" />
<h2 className="text-4xl text-gray-800">Mobile not supported</h2>
<p className="text-sm text-gray-500">
<div className="flex flex-col items-center gap-8 text-center w-lg">
<Smartphone className="h-10 w-10 text-foreground" />
<h2 className="text-4xl text-foreground">Mobile not supported</h2>
<p className="text-sm text-muted-foreground">
Hey there! Thanks for checking out Colanode.
</p>
<p className="text-sm text-gray-500">
<p className="text-sm text-muted-foreground">
Right now, Colanode is not quite ready for mobile devices just yet.
For the best experience, please hop onto a desktop or laptop. We're
working hard to bring you an awesome mobile experience soon.
</p>
<p className="text-sm text-gray-500 mt-4">
<p className="text-sm text-muted-foreground mt-4">
Thanks for your patience and support!
</p>
</div>

View File

@@ -1,11 +1,12 @@
import { EventBus } from '@colanode/client/lib';
import { MutationInput, MutationResult } from '@colanode/client/mutations';
import { QueryInput, QueryMap } from '@colanode/client/queries';
import { Event, TempFile } from '@colanode/client/types';
import { AppInitOutput, Event, TempFile } from '@colanode/client/types';
import { ColanodeWindowApi } from '@colanode/ui';
export interface ColanodeWorkerApi {
init: () => Promise<void>;
init: () => Promise<AppInitOutput>;
reset: () => Promise<void>;
executeMutation: <T extends MutationInput>(
input: T
) => Promise<MutationResult<T>>;
@@ -30,6 +31,15 @@ declare global {
}
}
export type BroadcastInitMessage = {
type: 'init';
};
export type BroadcastInitResultMessage = {
type: 'init_result';
result: AppInitOutput;
};
export type BroadcastMutationMessage = {
type: 'mutation';
mutationId: string;
@@ -83,6 +93,8 @@ export type BroadcastEventMessage = {
};
export type BroadcastMessage =
| BroadcastInitMessage
| BroadcastInitResultMessage
| BroadcastMutationMessage
| BroadcastMutationResultMessage
| BroadcastQueryMessage
@@ -92,6 +104,12 @@ export type BroadcastMessage =
| BroadcastQueryUnsubscribeMessage
| BroadcastEventMessage;
export type PendingInit = {
type: 'init';
resolve: (result: AppInitOutput) => void;
reject: (error: string) => void;
};
export type PendingQuery = {
type: 'query';
queryId: string;
@@ -119,6 +137,7 @@ export type PendingMutation = {
};
export type PendingPromise =
| PendingInit
| PendingQuery
| PendingQueryAndSubscribe
| PendingMutation;

View File

@@ -29,7 +29,11 @@ const initializeApp = async () => {
window.colanode = {
init: async () => {
await workerApi.init();
return workerApi.init();
},
reset: async () => {
await workerApi.reset();
window.location.reload();
},
executeMutation: async (input) => {
return workerApi.executeMutation(input);

View File

@@ -3,7 +3,7 @@ import '../../../packages/ui/src/styles/globals.css';
import { useRegisterSW } from 'virtual:pwa-register/react';
import { RootProvider } from '@colanode/ui';
import { App } from '@colanode/ui';
export const Root = () => {
useRegisterSW({
@@ -12,5 +12,5 @@ export const Root = () => {
},
});
return <RootProvider type="web" />;
return <App type="web" />;
};

View File

@@ -0,0 +1,114 @@
import { build } from '@colanode/core';
import { WebFileSystem } from '@colanode/web/services/file-system';
import { WebPathService } from '@colanode/web/services/path-service';
type BootstrapData = {
version: string;
};
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
const createDefaultData = (): BootstrapData => ({
version: build.version,
});
const parseBootstrap = (content: string): BootstrapData => {
try {
const parsed = JSON.parse(content);
return {
version: parsed.version ?? build.version,
};
} catch {
return createDefaultData();
}
};
const readBootstrap = async (
fs: WebFileSystem,
path: string
): Promise<BootstrapData> => {
try {
const data = await fs.readFile(path);
const content = textDecoder.decode(data);
return parseBootstrap(content);
} catch {
return createDefaultData();
}
};
const writeBootstrap = async (
fs: WebFileSystem,
path: string,
data: BootstrapData
): Promise<void> => {
const payload: BootstrapData = {
version: data.version,
};
const content = JSON.stringify(payload, null, 2);
await fs.writeFile(path, textEncoder.encode(content));
};
export class WebBootstrapService {
private data: BootstrapData;
private readonly fs: WebFileSystem;
private readonly bootstrapPath: string;
private readonly requiresFreshInstall: boolean;
private constructor(options: {
fs: WebFileSystem;
data: BootstrapData;
bootstrapPath: string;
requiresFreshInstall: boolean;
}) {
this.fs = options.fs;
this.data = options.data;
this.bootstrapPath = options.bootstrapPath;
this.requiresFreshInstall = options.requiresFreshInstall;
}
public static async create(
paths: WebPathService,
fs: WebFileSystem = new WebFileSystem()
): Promise<WebBootstrapService> {
const bootstrapPath = paths.bootstrap;
const [bootstrapExists, tempExists] = await Promise.all([
fs.exists(bootstrapPath),
fs.exists(paths.temp),
]);
const requiresFreshInstall = !bootstrapExists && tempExists;
const data = bootstrapExists
? await readBootstrap(fs, bootstrapPath)
: createDefaultData();
const service = new WebBootstrapService({
fs,
data,
bootstrapPath,
requiresFreshInstall,
});
return service;
}
private async save(): Promise<void> {
await writeBootstrap(this.fs, this.bootstrapPath, this.data);
}
public get needsFreshInstall(): boolean {
return this.requiresFreshInstall;
}
public get version(): string {
return this.data.version;
}
public async updateVersion(version: string): Promise<void> {
this.data.version = version;
await this.save();
}
}

View File

@@ -1,3 +1,5 @@
/// <reference lib="webworker" />
import { FileReadStream, FileSystem } from '@colanode/client/services';
export class WebFileSystem implements FileSystem {
@@ -84,6 +86,13 @@ export class WebFileSystem implements FileSystem {
return arrayBuffer;
}
public async reset(): Promise<void> {
const root = await this.ensureInitialized();
for await (const [name] of root.entries()) {
await root.removeEntry(name, { recursive: true });
}
}
public async makeDirectory(path: string): Promise<void> {
await this.getDirectoryHandle(path, true);
}

View File

@@ -2,20 +2,26 @@ import { PathService } from '@colanode/client/services';
export class WebPathService implements PathService {
private readonly appPath = '';
private readonly appDatabasePath = this.join(this.appPath, 'app.db');
private readonly accountsDirectoryPath = this.join(this.appPath, 'accounts');
private readonly assetsSourcePath = 'assets';
private readonly appDatabasePath = this.join(this.appPath, 'app.db');
private readonly bootstrapPath = this.join(this.appPath, 'bootstrap.json');
private readonly avatarsPath = this.join(this.appPath, 'avatars');
private readonly workspacesPath = this.join(this.appPath, 'workspaces');
public get app() {
return this.appPath;
}
public get bootstrap() {
return this.bootstrapPath;
}
public get appDatabase() {
return this.appDatabasePath;
}
public get accounts() {
return this.accountsDirectoryPath;
public get avatars() {
return this.avatarsPath;
}
public get temp() {
@@ -42,44 +48,28 @@ export class WebPathService implements PathService {
return this.join(this.appPath, 'temp', name);
}
public account(accountId: string): string {
return this.join(this.accountsDirectoryPath, accountId);
public avatar(avatarId: string): string {
return this.join(this.avatarsPath, avatarId + '.jpeg');
}
public accountDatabase(accountId: string): string {
return this.join(this.account(accountId), 'account.db');
public workspace(userId: string): string {
return this.join(this.workspacesPath, userId);
}
public workspace(accountId: string, workspaceId: string): string {
return this.join(this.account(accountId), 'workspaces', workspaceId);
public workspaceDatabase(userId: string): string {
return this.join(this.workspace(userId), 'workspace.db');
}
public workspaceDatabase(accountId: string, workspaceId: string): string {
return this.join(this.workspace(accountId, workspaceId), 'workspace.db');
}
public workspaceFiles(accountId: string, workspaceId: string): string {
return this.join(this.workspace(accountId, workspaceId), 'files');
public workspaceFiles(userId: string): string {
return this.join(this.workspace(userId), 'files');
}
public workspaceFile(
accountId: string,
workspaceId: string,
userId: string,
fileId: string,
extension: string
): string {
return this.join(
this.workspaceFiles(accountId, workspaceId),
fileId + extension
);
}
public accountAvatars(accountId: string): string {
return this.join(this.account(accountId), 'avatars');
}
public accountAvatar(accountId: string, avatarId: string): string {
return this.join(this.accountAvatars(accountId), avatarId + '.jpeg');
return this.join(this.workspaceFiles(userId), fileId + extension);
}
public dirname(path: string): string {
@@ -101,4 +91,8 @@ export class WebPathService implements PathService {
const parts = path.split('.');
return parts.length > 1 ? '.' + parts[parts.length - 1] : '';
}
public font(name: string): string {
return this.join(this.assetsSourcePath, 'fonts', name);
}
}

View File

@@ -1,11 +1,17 @@
import * as Comlink from 'comlink';
import { eventBus } from '@colanode/client/lib';
import { MutationInput, MutationResult } from '@colanode/client/mutations';
import {
MutationInput,
MutationResult,
TempFileCreateMutationInput,
} from '@colanode/client/mutations';
import { QueryInput, QueryMap } from '@colanode/client/queries';
import { AppMeta, AppService } from '@colanode/client/services';
import { extractFileSubtype, generateId, IdType } from '@colanode/core';
import { AppInitOutput } from '@colanode/client/types';
import { build, extractFileSubtype, generateId, IdType } from '@colanode/core';
import {
BroadcastInitMessage,
BroadcastMessage,
BroadcastMutationMessage,
BroadcastQueryAndSubscribeMessage,
@@ -14,6 +20,7 @@ import {
ColanodeWorkerApi,
PendingPromise,
} from '@colanode/web/lib/types';
import { WebBootstrapService } from '@colanode/web/services/bootstrap';
import { WebFileSystem } from '@colanode/web/services/file-system';
import { WebKyselyService } from '@colanode/web/services/kysely-service';
import { WebPathService } from '@colanode/web/services/path-service';
@@ -24,7 +31,7 @@ const pendingPromises = new Map<string, PendingPromise>();
const fs = new WebFileSystem();
const path = new WebPathService();
let app: AppService | null = null;
let appInitialized = false;
let appInitOutput: AppInitOutput | null = null;
const broadcast = new BroadcastChannel('colanode');
broadcast.onmessage = (event) => {
@@ -37,11 +44,40 @@ navigator.locks.request('colanode', async () => {
platform: navigator.userAgent,
};
const bootstrap = await WebBootstrapService.create(path, fs);
if (bootstrap.needsFreshInstall) {
appInitOutput = 'reset';
if (pendingPromises.has('init')) {
const promise = pendingPromises.get('init');
if (promise && promise.type === 'init') {
promise.resolve(appInitOutput);
}
}
broadcastMessage({
type: 'init_result',
result: appInitOutput,
});
return;
}
app = new AppService(appMeta, fs, new WebKyselyService(), path);
await app.migrate();
await app.init();
appInitialized = true;
await bootstrap.updateVersion(build.version);
await app.metadata.set('app', 'version', build.version);
await app.metadata.set('app', 'platform', appMeta.platform);
appInitOutput = 'success';
broadcastMessage({
type: 'init_result',
result: appInitOutput,
});
const ids = Array.from(pendingPromises.keys());
for (const id of ids) {
@@ -50,7 +86,9 @@ navigator.locks.request('colanode', async () => {
continue;
}
if (promise.type === 'query') {
if (promise.type === 'init') {
promise.resolve(appInitOutput);
} else if (promise.type === 'query') {
const result = await app.mediator.executeQuery(promise.input);
promise.resolve(result);
} else if (promise.type === 'query_and_subscribe') {
@@ -84,7 +122,16 @@ const broadcastMessage = (message: BroadcastMessage) => {
};
const handleMessage = async (message: BroadcastMessage) => {
if (message.type === 'event') {
if (message.type === 'init') {
if (!appInitOutput) {
return;
}
broadcastMessage({
type: 'init_result',
result: appInitOutput,
});
} else if (message.type === 'event') {
if (message.windowId === windowId) {
return;
}
@@ -137,6 +184,14 @@ const handleMessage = async (message: BroadcastMessage) => {
}
app.mediator.unsubscribeQuery(message.key, message.windowId);
} else if (message.type === 'init_result') {
const promise = pendingPromises.get('init');
if (!promise || promise.type !== 'init') {
return;
}
promise.resolve(message.result);
pendingPromises.delete('init');
} else if (message.type === 'query_result') {
const promise = pendingPromises.get(message.queryId);
if (!promise || promise.type !== 'query') {
@@ -164,124 +219,163 @@ const handleMessage = async (message: BroadcastMessage) => {
}
};
const waitForInit = async () => {
let count = 0;
while (!appInitOutput) {
await new Promise((resolve) => setTimeout(resolve, 50));
count++;
if (count > 100) {
throw new Error('App initialization timed out');
}
}
};
const api: ColanodeWorkerApi = {
async init() {
if (!app) {
return;
if (appInitOutput) {
return appInitOutput;
}
if (appInitialized) {
return;
}
if (!appInitOutput) {
const message: BroadcastInitMessage = {
type: 'init',
};
let count = 0;
while (!appInitialized) {
await new Promise((resolve) => setTimeout(resolve, 50));
count++;
if (count > 100) {
throw new Error('App initialization timed out');
}
}
},
executeMutation(input) {
if (app) {
return app.mediator.executeMutation(input);
}
const mutationId = generateId(IdType.Mutation);
const message: BroadcastMutationMessage = {
type: 'mutation',
mutationId,
input,
};
const promise = new Promise<MutationResult<MutationInput>>(
(resolve, reject) => {
pendingPromises.set(mutationId, {
type: 'mutation',
mutationId,
input,
const promise = new Promise<AppInitOutput>((resolve, reject) => {
pendingPromises.set('init', {
type: 'init',
resolve,
reject,
});
}
);
broadcastMessage(message);
return promise;
},
executeQuery(input) {
if (app) {
return app.mediator.executeQuery(input);
});
broadcastMessage(message);
return promise;
}
const queryId = generateId(IdType.Query);
const message: BroadcastQueryMessage = {
type: 'query',
queryId,
input,
};
const promise = new Promise<QueryMap[QueryInput['type']]['output']>(
(resolve, reject) => {
pendingPromises.set(queryId, {
type: 'query',
queryId,
input,
resolve,
reject,
});
}
);
broadcastMessage(message);
return promise;
},
executeQueryAndSubscribe(key, input) {
if (app) {
return app.mediator.executeQueryAndSubscribe(key, windowId, input);
await waitForInit();
if (!appInitOutput) {
return Promise.reject(new Error('App not initialized'));
}
const queryId = generateId(IdType.Query);
const message: BroadcastQueryAndSubscribeMessage = {
type: 'query_and_subscribe',
queryId,
key,
windowId,
input,
};
const promise = new Promise<QueryMap[QueryInput['type']]['output']>(
(resolve, reject) => {
pendingPromises.set(queryId, {
type: 'query_and_subscribe',
queryId,
key,
windowId,
input,
resolve,
reject,
});
}
);
broadcastMessage(message);
return promise;
return appInitOutput;
},
unsubscribeQuery(key) {
if (app) {
app.mediator.unsubscribeQuery(key, windowId);
async reset() {
await fs.reset();
},
async executeMutation(input) {
if (!appInitOutput) {
const mutationId = generateId(IdType.Mutation);
const message: BroadcastMutationMessage = {
type: 'mutation',
mutationId,
input,
};
const promise = new Promise<MutationResult<MutationInput>>(
(resolve, reject) => {
pendingPromises.set(mutationId, {
type: 'mutation',
mutationId,
input,
resolve,
reject,
});
}
);
broadcastMessage(message);
return promise;
}
if (!app || appInitOutput !== 'success') {
return Promise.reject(new Error('App not initialized'));
}
return app.mediator.executeMutation(input);
},
async executeQuery(input) {
if (!appInitOutput) {
const queryId = generateId(IdType.Query);
const message: BroadcastQueryMessage = {
type: 'query',
queryId,
input,
};
const promise = new Promise<QueryMap[QueryInput['type']]['output']>(
(resolve, reject) => {
pendingPromises.set(queryId, {
type: 'query',
queryId,
input,
resolve,
reject,
});
}
);
broadcastMessage(message);
return promise;
}
if (!app || appInitOutput !== 'success') {
return Promise.reject(new Error('App not initialized'));
}
return app.mediator.executeQuery(input);
},
async executeQueryAndSubscribe(key, input) {
if (!appInitOutput) {
const queryId = generateId(IdType.Query);
const message: BroadcastQueryAndSubscribeMessage = {
type: 'query_and_subscribe',
queryId,
key,
windowId,
input,
};
const promise = new Promise<QueryMap[QueryInput['type']]['output']>(
(resolve, reject) => {
pendingPromises.set(queryId, {
type: 'query_and_subscribe',
queryId,
key,
windowId,
input,
resolve,
reject,
});
}
);
broadcastMessage(message);
return promise;
}
if (!app || appInitOutput !== 'success') {
return Promise.reject(new Error('App not initialized'));
}
return app.mediator.executeQuery(input);
},
async unsubscribeQuery(key) {
if (!appInitOutput) {
const message: BroadcastQueryUnsubscribeMessage = {
type: 'query_unsubscribe',
key,
windowId,
};
broadcastMessage(message);
return Promise.resolve();
}
const message: BroadcastQueryUnsubscribeMessage = {
type: 'query_unsubscribe',
key,
windowId,
};
if (!app || appInitOutput !== 'success') {
return;
}
broadcastMessage(message);
return Promise.resolve();
return app.mediator.unsubscribeQuery(key, windowId);
},
subscribe(callback) {
const id = eventBus.subscribe(callback);
@@ -305,38 +399,41 @@ const api: ColanodeWorkerApi = {
const fileData = new Uint8Array(arrayBuffer);
await fs.writeFile(filePath, fileData);
if (app) {
await app.database
.insertInto('temp_files')
.values({
id,
name: file.name,
size: file.size,
mime_type: mimeType,
subtype,
path: filePath,
extension,
created_at: new Date().toISOString(),
opened_at: new Date().toISOString(),
})
.execute();
const input: TempFileCreateMutationInput = {
type: 'temp.file.create',
id,
name: file.name,
size: file.size,
mimeType,
subtype,
extension,
path: filePath,
};
if (app && appInitOutput === 'success') {
await app.mediator.executeMutation(input);
} else {
const mutationId = generateId(IdType.Mutation);
const message: BroadcastMutationMessage = {
type: 'mutation',
mutationId: generateId(IdType.Mutation),
input: {
type: 'temp.file.create',
id,
name: file.name,
size: file.size,
mimeType,
subtype,
extension,
path: filePath,
},
mutationId,
input,
};
const promise = new Promise<MutationResult<MutationInput>>(
(resolve, reject) => {
pendingPromises.set(mutationId, {
type: 'mutation',
mutationId,
input,
resolve,
reject,
});
}
);
broadcastMessage(message);
await promise;
}
const url = await fs.url(filePath);

View File

@@ -7,7 +7,7 @@
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"types": ["vite/client", "vite-plugin-pwa/client"],
"allowImportingTsExtensions": true,
"noEmit": true,

11595
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
export * from './schema';
export * from './migrations';

View File

@@ -1,11 +0,0 @@
import { Migration } from 'kysely';
import { createWorkspacesTable } from './00001-create-workspaces-table';
import { createMetadataTable } from './00002-create-metadata-table';
import { createAvatarsTable } from './00003-create-avatars-table';
export const accountDatabaseMigrations: Record<string, Migration> = {
'00001-create-workspaces-table': createWorkspacesTable,
'00002-create-metadata-table': createMetadataTable,
'00003-create-avatars-table': createAvatarsTable,
};

View File

@@ -1,49 +0,0 @@
import { ColumnType, Insertable, Selectable, Updateable } from 'kysely';
import { WorkspaceRole } from '@colanode/core';
interface WorkspaceTable {
id: ColumnType<string, string, never>;
user_id: ColumnType<string, string, never>;
account_id: ColumnType<string, string, never>;
name: ColumnType<string, string, string>;
description: ColumnType<string | null, string | null, string | null>;
avatar: ColumnType<string | null, string | null, string | null>;
role: ColumnType<WorkspaceRole, WorkspaceRole, WorkspaceRole>;
storage_limit: ColumnType<string, string, string>;
max_file_size: ColumnType<string, string, string>;
created_at: ColumnType<string, string, never>;
}
export type SelectWorkspace = Selectable<WorkspaceTable>;
export type CreateWorkspace = Insertable<WorkspaceTable>;
export type UpdateWorkspace = Updateable<WorkspaceTable>;
interface AccountMetadataTable {
key: ColumnType<string, string, never>;
value: ColumnType<string, string, string>;
created_at: ColumnType<string, string, never>;
updated_at: ColumnType<string | null, string | null, string | null>;
}
export type SelectAccountMetadata = Selectable<AccountMetadataTable>;
export type CreateAccountMetadata = Insertable<AccountMetadataTable>;
export type UpdateAccountMetadata = Updateable<AccountMetadataTable>;
interface AvatarTable {
id: ColumnType<string, string, never>;
path: ColumnType<string, string, string>;
size: ColumnType<number, number, number>;
created_at: ColumnType<string, string, never>;
opened_at: ColumnType<string, string, string>;
}
export type SelectAvatar = Selectable<AvatarTable>;
export type CreateAvatar = Insertable<AvatarTable>;
export type UpdateAvatar = Updateable<AvatarTable>;
export interface AccountDatabaseSchema {
workspaces: WorkspaceTable;
metadata: AccountMetadataTable;
avatars: AvatarTable;
}

View File

@@ -12,28 +12,6 @@ export const createServersTable: Migration = {
.addColumn('created_at', 'text', (col) => col.notNull())
.addColumn('synced_at', 'text')
.execute();
await db
.insertInto('servers')
.values([
{
domain: 'eu.colanode.com',
name: 'Colanode Cloud (EU)',
avatar: 'https://colanode.com/assets/flags/eu.svg',
attributes: '{}',
version: '0.2.0',
created_at: new Date().toISOString(),
},
{
domain: 'us.colanode.com',
name: 'Colanode Cloud (US)',
avatar: 'https://colanode.com/assets/flags/us.svg',
attributes: '{}',
version: '0.2.0',
created_at: new Date().toISOString(),
},
])
.execute();
},
down: async (db) => {
await db.schema.dropTable('servers').execute();

View File

@@ -4,10 +4,12 @@ export const createMetadataTable: Migration = {
up: async (db) => {
await db.schema
.createTable('metadata')
.addColumn('key', 'text', (col) => col.notNull().primaryKey())
.addColumn('namespace', 'text', (col) => col.notNull())
.addColumn('key', 'text', (col) => col.notNull())
.addColumn('value', 'text', (col) => col.notNull())
.addColumn('created_at', 'text', (col) => col.notNull())
.addColumn('updated_at', 'text')
.addPrimaryKeyConstraint('pk_metadata', ['namespace', 'key'])
.execute();
},
down: async (db) => {

View File

@@ -1,16 +0,0 @@
import { Migration } from 'kysely';
export const createDeletedTokensTable: Migration = {
up: async (db) => {
await db.schema
.createTable('deleted_tokens')
.addColumn('account_id', 'text', (col) => col.notNull())
.addColumn('token', 'text', (col) => col.notNull().primaryKey())
.addColumn('server', 'text', (col) => col.notNull())
.addColumn('created_at', 'text', (col) => col.notNull())
.execute();
},
down: async (db) => {
await db.schema.dropTable('deleted_tokens').execute();
},
};

View File

@@ -1,16 +0,0 @@
import { Migration } from 'kysely';
export const createMetadataTable: Migration = {
up: async (db) => {
await db.schema
.createTable('metadata')
.addColumn('key', 'text', (col) => col.notNull().primaryKey())
.addColumn('value', 'text', (col) => col.notNull())
.addColumn('created_at', 'text', (col) => col.notNull())
.addColumn('updated_at', 'text')
.execute();
},
down: async (db) => {
await db.schema.dropTable('metadata').execute();
},
};

View File

@@ -4,8 +4,8 @@ export const createWorkspacesTable: Migration = {
up: async (db) => {
await db.schema
.createTable('workspaces')
.addColumn('id', 'text', (col) => col.notNull().primaryKey())
.addColumn('user_id', 'text', (col) => col.notNull())
.addColumn('user_id', 'text', (col) => col.notNull().primaryKey())
.addColumn('workspace_id', 'text', (col) => col.notNull())
.addColumn('account_id', 'text', (col) => col.notNull())
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('description', 'text')
@@ -14,6 +14,7 @@ export const createWorkspacesTable: Migration = {
.addColumn('storage_limit', 'integer', (col) => col.notNull())
.addColumn('max_file_size', 'integer', (col) => col.notNull())
.addColumn('created_at', 'text', (col) => col.notNull())
.addColumn('updated_at', 'text')
.execute();
},
down: async (db) => {

View File

@@ -1,16 +0,0 @@
import { Migration } from 'kysely';
export const dropDeletedTokensTable: Migration = {
up: async (db) => {
await db.schema.dropTable('deleted_tokens').execute();
},
down: async (db) => {
await db.schema
.createTable('deleted_tokens')
.addColumn('account_id', 'text', (col) => col.notNull())
.addColumn('token', 'text', (col) => col.notNull().primaryKey())
.addColumn('server', 'text', (col) => col.notNull())
.addColumn('created_at', 'text', (col) => col.notNull())
.execute();
},
};

View File

@@ -0,0 +1,18 @@
import { Migration } from 'kysely';
export const createTabsTable: Migration = {
up: async (db) => {
await db.schema
.createTable('tabs')
.addColumn('id', 'text', (col) => col.primaryKey().notNull())
.addColumn('location', 'text', (col) => col.notNull())
.addColumn('index', 'text', (col) => col.notNull())
.addColumn('last_active_at', 'text', (col) => col.notNull())
.addColumn('created_at', 'text', (col) => col.notNull())
.addColumn('updated_at', 'text')
.execute();
},
down: async (db) => {
await db.schema.dropTable('tabs').execute();
},
};

View File

@@ -1,21 +1,23 @@
import { Migration } from 'kysely';
import { createServersTable } from './00001-create-servers-table';
import { createAccountsTable } from './00002-create-accounts-table';
import { createDeletedTokensTable } from './00003-create-deleted-tokens-table';
import { createMetadataTable } from './00004-create-metadata-table';
import { createMetadataTable } from './00002-create-metadata-table';
import { createAccountsTable } from './00003-create-accounts-table';
import { createWorkspacesTable } from './00004-create-workspaces-table';
import { createJobsTable } from './00005-create-jobs-table';
import { createJobSchedulesTable } from './00006-create-job-schedules-table';
import { dropDeletedTokensTable } from './00007-drop-deleted-tokens-table';
import { createTempFilesTable } from './00008-create-temp-files-table';
import { createTempFilesTable } from './00007-create-temp-files-table';
import { createAvatarsTable } from './00008-create-avatars-table';
import { createTabsTable } from './00009-create-tabs-table';
export const appDatabaseMigrations: Record<string, Migration> = {
'00001-create-servers-table': createServersTable,
'00002-create-accounts-table': createAccountsTable,
'00003-create-deleted-tokens-table': createDeletedTokensTable,
'00004-create-metadata-table': createMetadataTable,
'00002-create-metadata-table': createMetadataTable,
'00003-create-accounts-table': createAccountsTable,
'00004-create-workspaces-table': createWorkspacesTable,
'00005-create-jobs-table': createJobsTable,
'00006-create-job-schedules-table': createJobSchedulesTable,
'00007-drop-deleted-tokens-table': dropDeletedTokensTable,
'00008-create-temp-files-table': createTempFilesTable,
'00007-create-temp-files-table': createTempFilesTable,
'00008-create-avatars-table': createAvatarsTable,
'00009-create-tabs-table': createTabsTable,
};

View File

@@ -1,7 +1,7 @@
import { ColumnType, Insertable, Selectable, Updateable } from 'kysely';
import { JobScheduleStatus, JobStatus } from '@colanode/client/jobs';
import { FileSubtype } from '@colanode/core';
import { FileSubtype, WorkspaceRole } from '@colanode/core';
interface ServerTable {
domain: ColumnType<string, string, never>;
@@ -17,6 +17,18 @@ export type SelectServer = Selectable<ServerTable>;
export type CreateServer = Insertable<ServerTable>;
export type UpdateServer = Updateable<ServerTable>;
interface MetadataTable {
namespace: ColumnType<string, string, never>;
key: ColumnType<string, string, never>;
value: ColumnType<string, string, string>;
created_at: ColumnType<string, string, never>;
updated_at: ColumnType<string | null, string | null, string | null>;
}
export type SelectMetadata = Selectable<MetadataTable>;
export type CreateMetadata = Insertable<MetadataTable>;
export type UpdateMetadata = Updateable<MetadataTable>;
interface AccountTable {
id: ColumnType<string, string, never>;
server: ColumnType<string, string, never>;
@@ -34,17 +46,6 @@ export type SelectAccount = Selectable<AccountTable>;
export type CreateAccount = Insertable<AccountTable>;
export type UpdateAccount = Updateable<AccountTable>;
interface AppMetadataTable {
key: ColumnType<string, string, never>;
value: ColumnType<string, string, string>;
created_at: ColumnType<string, string, never>;
updated_at: ColumnType<string | null, string | null, string | null>;
}
export type SelectAppMetadata = Selectable<AppMetadataTable>;
export type CreateAppMetadata = Insertable<AppMetadataTable>;
export type UpdateAppMetadata = Updateable<AppMetadataTable>;
export interface JobTableSchema {
id: ColumnType<string, string, never>;
queue: ColumnType<string, string, never>;
@@ -97,11 +98,57 @@ export type SelectTempFile = Selectable<TempFileTable>;
export type InsertTempFile = Insertable<TempFileTable>;
export type UpdateTempFile = Updateable<TempFileTable>;
interface TabTable {
id: ColumnType<string, string, never>;
location: ColumnType<string, string, string>;
index: ColumnType<string, string, string>;
last_active_at: ColumnType<string, string, string>;
created_at: ColumnType<string, string, string>;
updated_at: ColumnType<string | null, string | null, string | null>;
}
export type SelectTab = Selectable<TabTable>;
export type InsertTab = Insertable<TabTable>;
export type UpdateTab = Updateable<TabTable>;
interface WorkspacesTable {
user_id: ColumnType<string, string, never>;
workspace_id: ColumnType<string, string, string>;
account_id: ColumnType<string, string, string>;
name: ColumnType<string, string, string>;
description: ColumnType<string | null, string | null, string | null>;
avatar: ColumnType<string | null, string | null, string | null>;
role: ColumnType<WorkspaceRole, WorkspaceRole, WorkspaceRole>;
storage_limit: ColumnType<string, string, string>;
max_file_size: ColumnType<string, string, string>;
created_at: ColumnType<string, string, string>;
updated_at: ColumnType<string | null, string | null, string | null>;
}
export type SelectWorkspace = Selectable<WorkspacesTable>;
export type InsertWorkspace = Insertable<WorkspacesTable>;
export type UpdateWorkspace = Updateable<WorkspacesTable>;
interface AvatarsTable {
id: ColumnType<string, string, never>;
path: ColumnType<string, string, string>;
size: ColumnType<number, number, number>;
created_at: ColumnType<string, string, string>;
opened_at: ColumnType<string, string, string>;
}
export type SelectAvatar = Selectable<AvatarsTable>;
export type InsertAvatar = Insertable<AvatarsTable>;
export type UpdateAvatar = Updateable<AvatarsTable>;
export interface AppDatabaseSchema {
servers: ServerTable;
metadata: MetadataTable;
accounts: AccountTable;
metadata: AppMetadataTable;
workspaces: WorkspacesTable;
jobs: JobTableSchema;
job_schedules: JobScheduleTableSchema;
temp_files: TempFileTable;
avatars: AvatarsTable;
tabs: TabTable;
}

View File

@@ -1,24 +0,0 @@
import { Migration } from 'kysely';
export const createFileStatesTable: Migration = {
up: async (db) => {
await db.schema
.createTable('file_states')
.addColumn('id', 'text', (col) => col.notNull().primaryKey())
.addColumn('version', 'text', (col) => col.notNull())
.addColumn('download_status', 'integer')
.addColumn('download_progress', 'integer')
.addColumn('download_retries', 'integer')
.addColumn('download_started_at', 'text')
.addColumn('download_completed_at', 'text')
.addColumn('upload_status', 'integer')
.addColumn('upload_progress', 'integer')
.addColumn('upload_retries', 'integer')
.addColumn('upload_started_at', 'text')
.addColumn('upload_completed_at', 'text')
.execute();
},
down: async (db) => {
await db.schema.dropTable('file_states').execute();
},
};

View File

@@ -1,16 +0,0 @@
import { Migration } from 'kysely';
export const createMetadataTable: Migration = {
up: async (db) => {
await db.schema
.createTable('metadata')
.addColumn('key', 'text', (col) => col.notNull().primaryKey())
.addColumn('value', 'text', (col) => col.notNull())
.addColumn('created_at', 'text', (col) => col.notNull())
.addColumn('updated_at', 'text')
.execute();
},
down: async (db) => {
await db.schema.dropTable('metadata').execute();
},
};

View File

@@ -6,14 +6,15 @@ export const createLocalFilesTable: Migration = {
.createTable('local_files')
.addColumn('id', 'text', (col) => col.notNull().primaryKey())
.addColumn('version', 'text', (col) => col.notNull())
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('path', 'text', (col) => col.notNull())
.addColumn('extension', 'text', (col) => col.notNull())
.addColumn('size', 'integer', (col) => col.notNull())
.addColumn('subtype', 'text', (col) => col.notNull())
.addColumn('mime_type', 'text', (col) => col.notNull())
.addColumn('created_at', 'text', (col) => col.notNull())
.addColumn('opened_at', 'text', (col) => col.notNull())
.addColumn('download_status', 'integer', (col) => col.notNull())
.addColumn('download_progress', 'integer', (col) => col.notNull())
.addColumn('download_retries', 'integer', (col) => col.notNull())
.addColumn('download_completed_at', 'text')
.addColumn('download_error_code', 'text')
.addColumn('download_error_message', 'text')
.execute();
},
down: async (db) => {

View File

@@ -1,49 +0,0 @@
import { Migration } from 'kysely';
import { CreateUpload } from '@colanode/client/databases/workspace';
import { UploadStatus } from '@colanode/client/types/files';
export const dropFileStatesTable: Migration = {
up: async (db) => {
const pendingUploads = await db
.selectFrom('file_states')
.select(['id'])
.where('upload_status', '=', 1)
.execute();
if (pendingUploads.length > 0) {
const uploadsToCreate: CreateUpload[] = pendingUploads.map((upload) => ({
file_id: upload.id,
status: UploadStatus.Pending,
progress: 0,
retries: 0,
created_at: new Date().toISOString(),
}));
await db
.insertInto('uploads')
.values(uploadsToCreate)
.onConflict((oc) => oc.column('file_id').doNothing())
.execute();
}
await db.schema.dropTable('file_states').execute();
},
down: async (db) => {
await db.schema
.createTable('file_states')
.addColumn('id', 'text', (col) => col.notNull().primaryKey())
.addColumn('version', 'text', (col) => col.notNull())
.addColumn('download_status', 'integer')
.addColumn('download_progress', 'integer')
.addColumn('download_retries', 'integer')
.addColumn('download_started_at', 'text')
.addColumn('download_completed_at', 'text')
.addColumn('upload_status', 'integer')
.addColumn('upload_progress', 'integer')
.addColumn('upload_retries', 'integer')
.addColumn('upload_started_at', 'text')
.addColumn('upload_completed_at', 'text')
.execute();
},
};

View File

@@ -12,17 +12,14 @@ import { createDocumentStatesTable } from './00009-create-document-states-table'
import { createDocumentUpdatesTable } from './00010-create-document-updates-table';
import { createDocumentTextsTable } from './00011-create-document-texts-table';
import { createCollaborationsTable } from './00012-create-collaborations-table';
import { createFileStatesTable } from './00013-create-file-states-table';
import { createMutationsTable } from './00014-create-mutations-table';
import { createTombstonesTable } from './00015-create-tombstones-table';
import { createCursorsTable } from './00016-create-cursors-table';
import { createMetadataTable } from './00017-create-metadata-table';
import { createNodeReferencesTable } from './00018-create-node-references-table';
import { createNodeCountersTable } from './00019-create-node-counters-table';
import { createLocalFilesTable } from './00020-create-local-files-table';
import { createUploadsTable } from './00021-create-uploads-table';
import { createDownloadsTable } from './00022-create-downloads-table';
import { dropFileStatesTable } from './00023-drop-file-states-table';
import { createMutationsTable } from './00013-create-mutations-table';
import { createTombstonesTable } from './00014-create-tombstones-table';
import { createCursorsTable } from './00015-create-cursors-table';
import { createNodeReferencesTable } from './00016-create-node-references-table';
import { createNodeCountersTable } from './00017-create-node-counters-table';
import { createLocalFilesTable } from './00018-create-local-files-table';
import { createUploadsTable } from './00019-create-uploads-table';
import { createDownloadsTable } from './00020-create-downloads-table';
export const workspaceDatabaseMigrations: Record<string, Migration> = {
'00001-create-users-table': createUsersTable,
@@ -37,15 +34,12 @@ export const workspaceDatabaseMigrations: Record<string, Migration> = {
'00010-create-document-updates-table': createDocumentUpdatesTable,
'00011-create-document-texts-table': createDocumentTextsTable,
'00012-create-collaborations-table': createCollaborationsTable,
'00013-create-file-states-table': createFileStatesTable,
'00014-create-mutations-table': createMutationsTable,
'00015-create-tombstones-table': createTombstonesTable,
'00016-create-cursors-table': createCursorsTable,
'00017-create-metadata-table': createMetadataTable,
'00018-create-node-references-table': createNodeReferencesTable,
'00019-create-node-counters-table': createNodeCountersTable,
'00020-create-local-files-table': createLocalFilesTable,
'00021-create-uploads-table': createUploadsTable,
'00022-create-downloads-table': createDownloadsTable,
'00023-drop-file-states-table': dropFileStatesTable,
'00013-create-mutations-table': createMutationsTable,
'00014-create-tombstones-table': createTombstonesTable,
'00015-create-cursors-table': createCursorsTable,
'00016-create-node-references-table': createNodeReferencesTable,
'00017-create-node-counters-table': createNodeCountersTable,
'00018-create-local-files-table': createLocalFilesTable,
'00019-create-uploads-table': createUploadsTable,
'00020-create-downloads-table': createDownloadsTable,
};

View File

@@ -1,10 +1,6 @@
import { ColumnType, Insertable, Selectable, Updateable } from 'kysely';
import {
DownloadStatus,
DownloadType,
UploadStatus,
} from '@colanode/client/types/files';
import { DownloadStatus, UploadStatus } from '@colanode/client/types/files';
import { NodeCounterType } from '@colanode/client/types/nodes';
import {
MutationType,
@@ -12,7 +8,6 @@ import {
WorkspaceRole,
UserStatus,
DocumentType,
FileSubtype,
} from '@colanode/core';
interface UserTable {
@@ -227,28 +222,26 @@ export type SelectCursor = Selectable<CursorTable>;
export type CreateCursor = Insertable<CursorTable>;
export type UpdateCursor = Updateable<CursorTable>;
interface MetadataTable {
key: ColumnType<string, string, never>;
value: ColumnType<string, string, string>;
created_at: ColumnType<string, string, never>;
updated_at: ColumnType<string | null, string | null, string | null>;
}
export type SelectWorkspaceMetadata = Selectable<MetadataTable>;
export type CreateWorkspaceMetadata = Insertable<MetadataTable>;
export type UpdateWorkspaceMetadata = Updateable<MetadataTable>;
interface LocalFileTable {
id: ColumnType<string, string, never>;
version: ColumnType<string, string, string>;
name: ColumnType<string, string, string>;
path: ColumnType<string, string, string>;
extension: ColumnType<string, string, string>;
size: ColumnType<number, number, number>;
subtype: ColumnType<FileSubtype, FileSubtype, FileSubtype>;
mime_type: ColumnType<string, string, string>;
created_at: ColumnType<string, string, never>;
opened_at: ColumnType<string, string, string>;
download_status: ColumnType<DownloadStatus, DownloadStatus, DownloadStatus>;
download_progress: ColumnType<number, number, number>;
download_retries: ColumnType<number, number, number>;
download_completed_at: ColumnType<
string | null,
string | null,
string | null
>;
download_error_code: ColumnType<string | null, string | null, string | null>;
download_error_message: ColumnType<
string | null,
string | null,
string | null
>;
}
export type SelectLocalFile = Selectable<LocalFileTable>;
@@ -275,7 +268,6 @@ interface DownloadTable {
id: ColumnType<string, string, never>;
file_id: ColumnType<string, string, never>;
version: ColumnType<string, string, string>;
type: ColumnType<DownloadType, DownloadType, never>;
name: ColumnType<string, string, string>;
path: ColumnType<string, string, string>;
size: ColumnType<number, number, number>;
@@ -293,6 +285,7 @@ interface DownloadTable {
export type SelectDownload = Selectable<DownloadTable>;
export type CreateDownload = Insertable<DownloadTable>;
export type UpdateDownload = Updateable<DownloadTable>;
export interface WorkspaceDatabaseSchema {
users: UserTable;
nodes: NodeTable;
@@ -314,5 +307,4 @@ export interface WorkspaceDatabaseSchema {
mutations: MutationTable;
tombstones: TombstoneTable;
cursors: CursorTable;
metadata: MetadataTable;
}

View File

@@ -1,52 +0,0 @@
import { eventBus } from '@colanode/client/lib/event-bus';
import { mapAccountMetadata } from '@colanode/client/lib/mappers';
import { MutationHandler } from '@colanode/client/lib/types';
import {
AccountMetadataDeleteMutationInput,
AccountMetadataDeleteMutationOutput,
} from '@colanode/client/mutations/accounts/account-metadata-delete';
import { AppService } from '@colanode/client/services/app-service';
export class AccountMetadataDeleteMutationHandler
implements MutationHandler<AccountMetadataDeleteMutationInput>
{
private readonly app: AppService;
constructor(appService: AppService) {
this.app = appService;
}
async handleMutation(
input: AccountMetadataDeleteMutationInput
): Promise<AccountMetadataDeleteMutationOutput> {
const account = this.app.getAccount(input.accountId);
if (!account) {
return {
success: false,
};
}
const deletedMetadata = await account.database
.deleteFrom('metadata')
.where('key', '=', input.key)
.returningAll()
.executeTakeFirst();
if (!deletedMetadata) {
return {
success: true,
};
}
eventBus.publish({
type: 'account.metadata.deleted',
accountId: input.accountId,
metadata: mapAccountMetadata(deletedMetadata),
});
return {
success: true,
};
}
}

View File

@@ -1,62 +0,0 @@
import { eventBus } from '@colanode/client/lib/event-bus';
import { mapAccountMetadata } from '@colanode/client/lib/mappers';
import { MutationHandler } from '@colanode/client/lib/types';
import {
AccountMetadataUpdateMutationInput,
AccountMetadataUpdateMutationOutput,
} from '@colanode/client/mutations/accounts/account-metadata-update';
import { AppService } from '@colanode/client/services/app-service';
export class AccountMetadataUpdateMutationHandler
implements MutationHandler<AccountMetadataUpdateMutationInput>
{
private readonly app: AppService;
constructor(appService: AppService) {
this.app = appService;
}
public async handleMutation(
input: AccountMetadataUpdateMutationInput
): Promise<AccountMetadataUpdateMutationOutput> {
const account = this.app.getAccount(input.accountId);
if (!account) {
return {
success: false,
};
}
const updatedMetadata = await account.database
.insertInto('metadata')
.returningAll()
.values({
key: input.key,
value: JSON.stringify(input.value),
created_at: new Date().toISOString(),
})
.onConflict((cb) =>
cb.columns(['key']).doUpdateSet({
value: JSON.stringify(input.value),
updated_at: new Date().toISOString(),
})
)
.executeTakeFirst();
if (!updatedMetadata) {
return {
success: false,
};
}
eventBus.publish({
type: 'account.metadata.updated',
accountId: input.accountId,
metadata: mapAccountMetadata(updatedMetadata),
});
return {
success: true,
};
}
}

View File

@@ -16,63 +16,70 @@ export abstract class AccountMutationHandlerBase {
login: LoginSuccessOutput,
server: ServerService
): Promise<void> {
const createdAccount = await this.app.database
.insertInto('accounts')
.returningAll()
.values({
id: login.account.id,
email: login.account.email,
name: login.account.name,
server: server.domain,
token: login.token,
device_id: login.deviceId,
avatar: login.account.avatar,
created_at: new Date().toISOString(),
})
.executeTakeFirst();
const { createdAccount, createdWorkspaces } = await this.app.database
.transaction()
.execute(async (trx) => {
const createdAccount = await trx
.insertInto('accounts')
.returningAll()
.values({
id: login.account.id,
email: login.account.email,
name: login.account.name,
server: server.domain,
token: login.token,
device_id: login.deviceId,
avatar: login.account.avatar,
created_at: new Date().toISOString(),
})
.executeTakeFirst();
if (!createdAccount) {
throw new MutationError(
MutationErrorCode.AccountLoginFailed,
'Account login failed, please try again.'
);
}
if (!createdAccount) {
throw new MutationError(
MutationErrorCode.AccountLoginFailed,
'Account login failed, please try again.'
);
}
const createdWorkspaces = [];
if (login.workspaces.length > 0) {
for (const workspace of login.workspaces) {
const createdWorkspace = await trx
.insertInto('workspaces')
.returningAll()
.values({
workspace_id: workspace.id,
name: workspace.name,
user_id: workspace.user.id,
account_id: createdAccount.id,
role: workspace.user.role,
storage_limit: workspace.user.storageLimit,
max_file_size: workspace.user.maxFileSize,
avatar: workspace.avatar,
description: workspace.description,
created_at: new Date().toISOString(),
})
.executeTakeFirst();
if (createdWorkspace) {
createdWorkspaces.push(createdWorkspace);
}
}
}
return { createdAccount, createdWorkspaces };
});
const account = mapAccount(createdAccount);
const accountService = await this.app.initAccount(account);
await this.app.initAccount(account);
eventBus.publish({
type: 'account.created',
account: account,
});
if (login.workspaces.length === 0) {
return;
}
for (const workspace of login.workspaces) {
const createdWorkspace = await accountService.database
.insertInto('workspaces')
.returningAll()
.values({
id: workspace.id,
name: workspace.name,
user_id: workspace.user.id,
account_id: account.id,
role: workspace.user.role,
storage_limit: workspace.user.storageLimit,
max_file_size: workspace.user.maxFileSize,
avatar: workspace.avatar,
description: workspace.description,
created_at: new Date().toISOString(),
})
.executeTakeFirst();
if (!createdWorkspace) {
continue;
}
await accountService.initWorkspace(mapWorkspace(createdWorkspace));
for (const createdWorkspace of createdWorkspaces) {
await this.app.initWorkspace(createdWorkspace);
eventBus.publish({
type: 'workspace.created',
workspace: mapWorkspace(createdWorkspace),

View File

@@ -1,14 +1,14 @@
import { eventBus } from '@colanode/client/lib/event-bus';
import { mapAppMetadata } from '@colanode/client/lib/mappers';
import { mapMetadata } from '@colanode/client/lib/mappers';
import { MutationHandler } from '@colanode/client/lib/types';
import {
AppMetadataDeleteMutationInput,
AppMetadataDeleteMutationOutput,
} from '@colanode/client/mutations/apps/app-metadata-delete';
MetadataDeleteMutationInput,
MetadataDeleteMutationOutput,
} from '@colanode/client/mutations/apps/metadata-delete';
import { AppService } from '@colanode/client/services/app-service';
export class AppMetadataDeleteMutationHandler
implements MutationHandler<AppMetadataDeleteMutationInput>
export class MetadataDeleteMutationHandler
implements MutationHandler<MetadataDeleteMutationInput>
{
private readonly app: AppService;
@@ -17,10 +17,11 @@ export class AppMetadataDeleteMutationHandler
}
async handleMutation(
input: AppMetadataDeleteMutationInput
): Promise<AppMetadataDeleteMutationOutput> {
input: MetadataDeleteMutationInput
): Promise<MetadataDeleteMutationOutput> {
const deletedMetadata = await this.app.database
.deleteFrom('metadata')
.where('namespace', '=', input.namespace)
.where('key', '=', input.key)
.returningAll()
.executeTakeFirst();
@@ -32,8 +33,8 @@ export class AppMetadataDeleteMutationHandler
}
eventBus.publish({
type: 'app.metadata.deleted',
metadata: mapAppMetadata(deletedMetadata),
type: 'metadata.deleted',
metadata: mapMetadata(deletedMetadata),
});
return {

View File

@@ -1,14 +1,14 @@
import { eventBus } from '@colanode/client/lib/event-bus';
import { mapAppMetadata } from '@colanode/client/lib/mappers';
import { mapMetadata } from '@colanode/client/lib/mappers';
import { MutationHandler } from '@colanode/client/lib/types';
import {
AppMetadataUpdateMutationInput,
AppMetadataUpdateMutationOutput,
} from '@colanode/client/mutations/apps/app-metadata-update';
MetadataUpdateMutationInput,
MetadataUpdateMutationOutput,
} from '@colanode/client/mutations/apps/metadata-update';
import { AppService } from '@colanode/client/services/app-service';
export class AppMetadataUpdateMutationHandler
implements MutationHandler<AppMetadataUpdateMutationInput>
export class MetadataUpdateMutationHandler
implements MutationHandler<MetadataUpdateMutationInput>
{
private readonly app: AppService;
@@ -17,19 +17,20 @@ export class AppMetadataUpdateMutationHandler
}
async handleMutation(
input: AppMetadataUpdateMutationInput
): Promise<AppMetadataUpdateMutationOutput> {
input: MetadataUpdateMutationInput
): Promise<MetadataUpdateMutationOutput> {
const updatedMetadata = await this.app.database
.insertInto('metadata')
.returningAll()
.values({
namespace: input.namespace,
key: input.key,
value: JSON.stringify(input.value),
value: input.value,
created_at: new Date().toISOString(),
})
.onConflict((cb) =>
cb.columns(['key']).doUpdateSet({
value: JSON.stringify(input.value),
cb.columns(['namespace', 'key']).doUpdateSet({
value: input.value,
updated_at: new Date().toISOString(),
})
)
@@ -42,8 +43,8 @@ export class AppMetadataUpdateMutationHandler
}
eventBus.publish({
type: 'app.metadata.updated',
metadata: mapAppMetadata(updatedMetadata),
type: 'metadata.updated',
metadata: mapMetadata(updatedMetadata),
});
return {

View File

@@ -0,0 +1,56 @@
import { mapTab } from '@colanode/client/lib';
import { eventBus } from '@colanode/client/lib/event-bus';
import { MutationHandler } from '@colanode/client/lib/types';
import {
TabCreateMutationInput,
TabCreateMutationOutput,
} from '@colanode/client/mutations/apps/tab-create';
import { AppService } from '@colanode/client/services/app-service';
export class TabCreateMutationHandler
implements MutationHandler<TabCreateMutationInput>
{
private readonly app: AppService;
constructor(appService: AppService) {
this.app = appService;
}
async handleMutation(
input: TabCreateMutationInput
): Promise<TabCreateMutationOutput> {
const createdTab = await this.app.database
.insertInto('tabs')
.returningAll()
.values({
id: input.id,
location: input.location,
index: input.index,
last_active_at: new Date().toISOString(),
created_at: new Date().toISOString(),
})
.onConflict((cb) =>
cb.columns(['id']).doUpdateSet({
location: input.location,
index: input.index,
updated_at: new Date().toISOString(),
})
)
.executeTakeFirst();
if (!createdTab) {
return {
success: false,
};
}
eventBus.publish({
type: 'tab.created',
tab: mapTab(createdTab),
});
return {
success: true,
};
}
}

View File

@@ -0,0 +1,43 @@
import { mapTab } from '@colanode/client/lib';
import { eventBus } from '@colanode/client/lib/event-bus';
import { MutationHandler } from '@colanode/client/lib/types';
import {
TabDeleteMutationInput,
TabDeleteMutationOutput,
} from '@colanode/client/mutations/apps/tab-delete';
import { AppService } from '@colanode/client/services/app-service';
export class TabDeleteMutationHandler
implements MutationHandler<TabDeleteMutationInput>
{
private readonly app: AppService;
constructor(appService: AppService) {
this.app = appService;
}
async handleMutation(
input: TabDeleteMutationInput
): Promise<TabDeleteMutationOutput> {
const deletedTab = await this.app.database
.deleteFrom('tabs')
.returningAll()
.where('id', '=', input.id)
.executeTakeFirst();
if (!deletedTab) {
return {
success: false,
};
}
eventBus.publish({
type: 'tab.deleted',
tab: mapTab(deletedTab),
});
return {
success: true,
};
}
}

View File

@@ -0,0 +1,49 @@
import { mapTab } from '@colanode/client/lib';
import { eventBus } from '@colanode/client/lib/event-bus';
import { MutationHandler } from '@colanode/client/lib/types';
import {
TabUpdateMutationInput,
TabUpdateMutationOutput,
} from '@colanode/client/mutations/apps/tab-update';
import { AppService } from '@colanode/client/services/app-service';
export class TabUpdateMutationHandler
implements MutationHandler<TabUpdateMutationInput>
{
private readonly app: AppService;
constructor(appService: AppService) {
this.app = appService;
}
async handleMutation(
input: TabUpdateMutationInput
): Promise<TabUpdateMutationOutput> {
const updatedTab = await this.app.database
.updateTable('tabs')
.returningAll()
.set({
location: input.location,
index: input.index,
last_active_at: input.lastActiveAt,
updated_at: new Date().toISOString(),
})
.where('id', '=', input.id)
.executeTakeFirst();
if (!updatedTab) {
return {
success: false,
};
}
eventBus.publish({
type: 'tab.updated',
tab: mapTab(updatedTab),
});
return {
success: true,
};
}
}

View File

@@ -54,7 +54,7 @@ export class AvatarUploadMutationHandler
.json<AvatarUploadResponse>();
await this.app.fs.delete(filePath);
await account.avatars.downloadAvatar(response.id);
await this.app.assets.downloadAvatar(account.id, response.id);
return {
id: response.id,

View File

@@ -14,7 +14,7 @@ export class ChannelCreateMutationHandler
async handleMutation(
input: ChannelCreateMutationInput
): Promise<ChannelCreateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
const space = await workspace.database
.selectFrom('nodes')

View File

@@ -12,7 +12,7 @@ export class ChannelDeleteMutationHandler
async handleMutation(
input: ChannelDeleteMutationInput
): Promise<ChannelDeleteMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
await workspace.nodes.deleteNode(input.channelId);
return {

View File

@@ -14,7 +14,7 @@ export class ChannelUpdateMutationHandler
async handleMutation(
input: ChannelUpdateMutationInput
): Promise<ChannelUpdateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
const result = await workspace.nodes.updateNode<ChannelAttributes>(
input.channelId,

View File

@@ -19,14 +19,14 @@ export class ChatCreateMutationHandler
public async handleMutation(
input: ChatCreateMutationInput
): Promise<ChatCreateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
const query = sql<ChatRow>`
SELECT id
FROM nodes
WHERE type = 'chat'
AND json_extract(attributes, '$.collaborators.${sql.raw(input.collaboratorId)}') is not null
AND json_extract(attributes, '$.collaborators.${sql.raw(input.userId)}') is not null
AND json_extract(attributes, '$.collaborators.${sql.raw(workspace.userId)}') is not null
`.compile(workspace.database);
const existingChats = await workspace.database.executeQuery(query);
@@ -42,7 +42,7 @@ export class ChatCreateMutationHandler
type: 'chat',
collaborators: {
[input.userId]: 'admin',
[workspace.userId]: 'admin',
[input.collaboratorId]: 'admin',
},
};

View File

@@ -19,7 +19,7 @@ export class DatabaseCreateMutationHandler
async handleMutation(
input: DatabaseCreateMutationInput
): Promise<DatabaseCreateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
const databaseId = generateId(IdType.Database);
const viewId = generateId(IdType.DatabaseView);

View File

@@ -12,7 +12,7 @@ export class DatabaseDeleteMutationHandler
async handleMutation(
input: DatabaseDeleteMutationInput
): Promise<DatabaseDeleteMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
await workspace.nodes.deleteNode(input.databaseId);
return {

View File

@@ -14,7 +14,7 @@ export class DatabaseNameFieldUpdateMutationHandler
async handleMutation(
input: DatabaseNameFieldUpdateMutationInput
): Promise<DatabaseNameFieldUpdateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
const result = await workspace.nodes.updateNode<DatabaseAttributes>(
input.databaseId,
(attributes) => {

View File

@@ -14,7 +14,7 @@ export class DatabaseUpdateMutationHandler
async handleMutation(
input: DatabaseUpdateMutationInput
): Promise<DatabaseUpdateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
const result = await workspace.nodes.updateNode<DatabaseAttributes>(
input.databaseId,
(attributes) => {

View File

@@ -23,7 +23,7 @@ export class FieldCreateMutationHandler
async handleMutation(
input: FieldCreateMutationInput
): Promise<FieldCreateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
if (input.fieldType === 'relation') {
if (!input.relationDatabaseId) {

View File

@@ -14,7 +14,7 @@ export class FieldDeleteMutationHandler
async handleMutation(
input: FieldDeleteMutationInput
): Promise<FieldDeleteMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
const result = await workspace.nodes.updateNode<DatabaseAttributes>(
input.databaseId,

View File

@@ -14,7 +14,7 @@ export class FieldNameUpdateMutationHandler
async handleMutation(
input: FieldNameUpdateMutationInput
): Promise<FieldNameUpdateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
const result = await workspace.nodes.updateNode<DatabaseAttributes>(
input.databaseId,

View File

@@ -20,7 +20,7 @@ export class SelectOptionCreateMutationHandler
async handleMutation(
input: SelectOptionCreateMutationInput
): Promise<SelectOptionCreateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
const id = generateId(IdType.SelectOption);
const result = await workspace.nodes.updateNode<DatabaseAttributes>(

View File

@@ -14,7 +14,7 @@ export class SelectOptionDeleteMutationHandler
async handleMutation(
input: SelectOptionDeleteMutationInput
): Promise<SelectOptionDeleteMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
const result = await workspace.nodes.updateNode<DatabaseAttributes>(
input.databaseId,

View File

@@ -14,7 +14,7 @@ export class SelectOptionUpdateMutationHandler
async handleMutation(
input: SelectOptionUpdateMutationInput
): Promise<SelectOptionUpdateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
const result = await workspace.nodes.updateNode<DatabaseAttributes>(
input.databaseId,

View File

@@ -19,7 +19,7 @@ export class ViewCreateMutationHandler
async handleMutation(
input: ViewCreateMutationInput
): Promise<ViewCreateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
const id = generateId(IdType.DatabaseView);
const otherViews = await workspace.database

View File

@@ -12,7 +12,7 @@ export class ViewDeleteMutationHandler
async handleMutation(
input: ViewDeleteMutationInput
): Promise<ViewDeleteMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
await workspace.nodes.deleteNode(input.viewId);
return {

View File

@@ -14,7 +14,7 @@ export class ViewNameUpdateMutationHandler
async handleMutation(
input: ViewNameUpdateMutationInput
): Promise<ViewNameUpdateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
const result = await workspace.nodes.updateNode<DatabaseViewAttributes>(
input.viewId,

View File

@@ -14,7 +14,7 @@ export class ViewUpdateMutationHandler
async handleMutation(
input: ViewUpdateMutationInput
): Promise<ViewUpdateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
const result = await workspace.nodes.updateNode<DatabaseViewAttributes>(
input.viewId,

View File

@@ -4,6 +4,7 @@ import {
DocumentUpdateMutationInput,
DocumentUpdateMutationOutput,
} from '@colanode/client/mutations';
import { decodeState } from '@colanode/crdt';
export class DocumentUpdateMutationHandler
extends WorkspaceMutationHandlerBase
@@ -12,8 +13,11 @@ export class DocumentUpdateMutationHandler
async handleMutation(
input: DocumentUpdateMutationInput
): Promise<DocumentUpdateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
await workspace.documents.updateDocument(input.documentId, input.update);
const workspace = this.getWorkspace(input.userId);
await workspace.documents.updateDocument(
input.documentId,
decodeState(input.update)
);
return {
success: true,

View File

@@ -13,7 +13,7 @@ export class FileCreateMutationHandler
async handleMutation(
input: FileCreateMutationInput
): Promise<FileCreateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
const fileId = generateId(IdType.File);
await workspace.files.createFile(fileId, input.tempFileId, input.parentId);

View File

@@ -12,7 +12,7 @@ export class FileDeleteMutationHandler
async handleMutation(
input: FileDeleteMutationInput
): Promise<FileDeleteMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const workspace = this.getWorkspace(input.userId);
await workspace.nodes.deleteNode(input.fileId);
return {

Some files were not shown because too many files have changed in this diff Show More