mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 11:47:47 +01:00
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:
22
.gitignore
vendored
22
.gitignore
vendored
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
132
apps/desktop/src/main/bootstrap.ts
Normal file
132
apps/desktop/src/main/bootstrap.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
5
apps/mobile/README.md
Normal 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
31
apps/mobile/app.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
BIN
apps/mobile/assets/adaptive-icon.png
Normal file
BIN
apps/mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/mobile/assets/favicon.png
Normal file
BIN
apps/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/mobile/assets/icon.png
Normal file
BIN
apps/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/mobile/assets/splash-icon.png
Normal file
BIN
apps/mobile/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
12
apps/mobile/index.html
Normal file
12
apps/mobile/index.html
Normal 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
44
apps/mobile/package.json
Normal 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
|
||||
}
|
||||
5
apps/mobile/postcss.config.mjs
Normal file
5
apps/mobile/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
192
apps/mobile/src/app.tsx
Normal file
192
apps/mobile/src/app.tsx
Normal 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
9
apps/mobile/src/index.ts
Normal 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);
|
||||
51
apps/mobile/src/lib/assets.ts
Normal file
51
apps/mobile/src/lib/assets.ts
Normal 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);
|
||||
};
|
||||
129
apps/mobile/src/lib/types.ts
Normal file
129
apps/mobile/src/lib/types.ts
Normal 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;
|
||||
137
apps/mobile/src/services/file-system.ts
Normal file
137
apps/mobile/src/services/file-system.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
167
apps/mobile/src/services/kysely-service.ts
Normal file
167
apps/mobile/src/services/kysely-service.ts
Normal 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?.();
|
||||
}
|
||||
}
|
||||
114
apps/mobile/src/services/path-service.ts
Normal file
114
apps/mobile/src/services/path-service.ts
Normal 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
257
apps/mobile/src/ui/main.tsx
Normal 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 />);
|
||||
8
apps/mobile/src/ui/root.tsx
Normal file
8
apps/mobile/src/ui/root.tsx
Normal 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
31
apps/mobile/tsconfig.json
Normal 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" }
|
||||
],
|
||||
}
|
||||
30
apps/mobile/vite.config.ts
Normal file
30
apps/mobile/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />;
|
||||
};
|
||||
|
||||
114
apps/web/src/services/bootstrap.ts
Normal file
114
apps/web/src/services/bootstrap.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
11595
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
export * from './schema';
|
||||
export * from './migrations';
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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) => {
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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) => {
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
@@ -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 {
|
||||
56
packages/client/src/handlers/mutations/apps/tab-create.ts
Normal file
56
packages/client/src/handlers/mutations/apps/tab-create.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
43
packages/client/src/handlers/mutations/apps/tab-delete.ts
Normal file
43
packages/client/src/handlers/mutations/apps/tab-delete.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
49
packages/client/src/handlers/mutations/apps/tab-update.ts
Normal file
49
packages/client/src/handlers/mutations/apps/tab-update.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user