Merge files with nodes

This commit is contained in:
Hakan Shehu
2025-02-12 21:45:02 +01:00
parent a9e028af52
commit f4ad5756f3
53 changed files with 598 additions and 1014 deletions

View File

@@ -1,39 +0,0 @@
import { Migration } from 'kysely';
export const createFilesTable: Migration = {
up: async (db) => {
await db.schema
.createTable('files')
.addColumn('id', 'text', (col) => col.notNull().primaryKey())
.addColumn('type', 'text', (col) => col.notNull())
.addColumn('parent_id', 'text', (col) => col.notNull())
.addColumn('root_id', 'text', (col) => col.notNull())
.addColumn('revision', 'integer', (col) => col.notNull())
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('original_name', 'text', (col) => col.notNull())
.addColumn('mime_type', 'text', (col) => col.notNull())
.addColumn('extension', 'text', (col) => col.notNull())
.addColumn('size', 'integer', (col) => col.notNull())
.addColumn('created_at', 'text', (col) => col.notNull())
.addColumn('created_by', 'text', (col) => col.notNull())
.addColumn('updated_at', 'text')
.addColumn('updated_by', 'text')
.addColumn('download_status', 'integer', (col) => col.notNull())
.addColumn('download_progress', 'integer', (col) => col.notNull())
.addColumn('download_retries', 'integer', (col) => col.notNull())
.addColumn('upload_status', 'integer', (col) => col.notNull())
.addColumn('upload_progress', 'integer', (col) => col.notNull())
.addColumn('upload_retries', 'integer', (col) => col.notNull())
.addColumn('status', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createIndex('files_parent_id_index')
.on('files')
.columns(['parent_id'])
.execute();
},
down: async (db) => {
await db.schema.dropTable('files').execute();
},
};

View File

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

View File

@@ -7,11 +7,11 @@ import { createNodeUpdatesTable } from './00004-create-node-updates-table';
import { createNodeInteractionsTable } from './00005-create-node-interactions-table'; import { createNodeInteractionsTable } from './00005-create-node-interactions-table';
import { createNodeReactionsTable } from './00006-create-node-reactions-table'; import { createNodeReactionsTable } from './00006-create-node-reactions-table';
import { createCollaborationsTable } from './00007-create-collaborations-table'; import { createCollaborationsTable } from './00007-create-collaborations-table';
import { createFilesTable } from './00008-create-files-table'; import { createDocumentsTable } from './00008-create-documents-table';
import { createMutationsTable } from './00009-create-mutations-table'; import { createDocumentUpdatesTable } from './00009-create-document-updates-table';
import { createTombstonesTable } from './00010-create-tombstones-table'; import { createFileStatesTable } from './00010-create-file-states-table';
import { createDocumentsTable } from './00011-create-documents-table'; import { createMutationsTable } from './00011-create-mutations-table';
import { createDocumentUpdatesTable } from './00012-create-document-updates-table'; import { createTombstonesTable } from './00012-create-tombstones-table';
import { createCursorsTable } from './00013-create-cursors-table'; import { createCursorsTable } from './00013-create-cursors-table';
import { createMetadataTable } from './00014-create-metadata-table'; import { createMetadataTable } from './00014-create-metadata-table';
@@ -23,11 +23,11 @@ export const workspaceDatabaseMigrations: Record<string, Migration> = {
'00005-create-node-interactions-table': createNodeInteractionsTable, '00005-create-node-interactions-table': createNodeInteractionsTable,
'00006-create-node-reactions-table': createNodeReactionsTable, '00006-create-node-reactions-table': createNodeReactionsTable,
'00007-create-collaborations-table': createCollaborationsTable, '00007-create-collaborations-table': createCollaborationsTable,
'00008-create-files-table': createFilesTable, '00008-create-documents-table': createDocumentsTable,
'00009-create-mutations-table': createMutationsTable, '00009-create-document-updates-table': createDocumentUpdatesTable,
'00010-create-tombstones-table': createTombstonesTable, '00010-create-file-states-table': createFileStatesTable,
'00011-create-documents-table': createDocumentsTable, '00011-create-mutations-table': createMutationsTable,
'00012-create-document-updates-table': createDocumentUpdatesTable, '00012-create-tombstones-table': createTombstonesTable,
'00013-create-cursors-table': createCursorsTable, '00013-create-cursors-table': createCursorsTable,
'00014-create-metadata-table': createMetadataTable, '00014-create-metadata-table': createMetadataTable,
}; };

View File

@@ -1,6 +1,4 @@
import { import {
FileStatus,
FileType,
MutationType, MutationType,
NodeType, NodeType,
WorkspaceRole, WorkspaceRole,
@@ -108,34 +106,61 @@ export type SelectCollaboration = Selectable<CollaborationTable>;
export type CreateCollaboration = Insertable<CollaborationTable>; export type CreateCollaboration = Insertable<CollaborationTable>;
export type UpdateCollaboration = Updateable<CollaborationTable>; export type UpdateCollaboration = Updateable<CollaborationTable>;
interface FileTable { interface DocumentTable {
id: ColumnType<string, string, never>; id: ColumnType<string, string, never>;
type: ColumnType<FileType, FileType, FileType>;
parent_id: ColumnType<string, string, string>;
root_id: ColumnType<string, string, string>;
revision: ColumnType<bigint, bigint, bigint>; revision: ColumnType<bigint, bigint, bigint>;
name: ColumnType<string, string, string>; state: ColumnType<Uint8Array, Uint8Array, Uint8Array>;
original_name: ColumnType<string, string, string>;
mime_type: ColumnType<string, string, string>;
extension: ColumnType<string, string, string>;
size: ColumnType<number, number, number>;
created_by: ColumnType<string, string, never>;
updated_by: ColumnType<string | null, string | null, string | null>;
deleted_at: ColumnType<string | null, never, string | null>;
download_status: ColumnType<DownloadStatus, DownloadStatus, DownloadStatus>;
download_progress: ColumnType<number, number, number>;
download_retries: ColumnType<number, number, number>;
upload_status: ColumnType<UploadStatus, UploadStatus, UploadStatus>;
upload_progress: ColumnType<number, number, number>;
upload_retries: ColumnType<number, number, number>;
created_at: ColumnType<string, string, never>; created_at: ColumnType<string, string, never>;
updated_at: ColumnType<string | null, string | null, string>; created_by: ColumnType<string, string, never>;
status: ColumnType<FileStatus, FileStatus, FileStatus>; updated_at: ColumnType<string | null, string | null, string | null>;
updated_by: ColumnType<string | null, string | null, string | null>;
} }
export type SelectFile = Selectable<FileTable>; export type SelectDocument = Selectable<DocumentTable>;
export type CreateFile = Insertable<FileTable>; export type CreateDocument = Insertable<DocumentTable>;
export type UpdateFile = Updateable<FileTable>; export type UpdateDocument = Updateable<DocumentTable>;
interface DocumentUpdateTable {
id: ColumnType<string, string, never>;
document_id: ColumnType<string, string, never>;
data: ColumnType<Uint8Array, Uint8Array, never>;
created_at: ColumnType<string, string, never>;
}
export type SelectDocumentUpdate = Selectable<DocumentUpdateTable>;
export type CreateDocumentUpdate = Insertable<DocumentUpdateTable>;
export type UpdateDocumentUpdate = Updateable<DocumentUpdateTable>;
interface FileStateTable {
id: ColumnType<string, string, never>;
version: ColumnType<string, string, string>;
download_status: ColumnType<
DownloadStatus | null,
DownloadStatus | null,
DownloadStatus | null
>;
download_progress: ColumnType<number | null, number | null, number | null>;
download_retries: ColumnType<number | null, number | null, number | null>;
download_started_at: ColumnType<string | null, string | null, string | null>;
download_completed_at: ColumnType<
string | null,
string | null,
string | null
>;
upload_status: ColumnType<
UploadStatus | null,
UploadStatus | null,
UploadStatus | null
>;
upload_progress: ColumnType<number | null, number | null, number | null>;
upload_retries: ColumnType<number | null, number | null, number | null>;
upload_started_at: ColumnType<string | null, string | null, string | null>;
upload_completed_at: ColumnType<string | null, string | null, string | null>;
}
export type SelectFileState = Selectable<FileStateTable>;
export type CreateFileState = Insertable<FileStateTable>;
export type UpdateFileState = Updateable<FileStateTable>;
interface MutationTable { interface MutationTable {
id: ColumnType<string, string, never>; id: ColumnType<string, string, never>;
@@ -181,31 +206,6 @@ export type SelectWorkspaceMetadata = Selectable<MetadataTable>;
export type CreateWorkspaceMetadata = Insertable<MetadataTable>; export type CreateWorkspaceMetadata = Insertable<MetadataTable>;
export type UpdateWorkspaceMetadata = Updateable<MetadataTable>; export type UpdateWorkspaceMetadata = Updateable<MetadataTable>;
interface DocumentTable {
id: ColumnType<string, string, never>;
revision: ColumnType<bigint, bigint, bigint>;
state: ColumnType<Uint8Array, Uint8Array, Uint8Array>;
created_at: ColumnType<string, string, never>;
created_by: ColumnType<string, string, never>;
updated_at: ColumnType<string | null, string | null, string | null>;
updated_by: ColumnType<string | null, string | null, string | null>;
}
export type SelectDocument = Selectable<DocumentTable>;
export type CreateDocument = Insertable<DocumentTable>;
export type UpdateDocument = Updateable<DocumentTable>;
interface DocumentUpdateTable {
id: ColumnType<string, string, never>;
document_id: ColumnType<string, string, never>;
data: ColumnType<Uint8Array, Uint8Array, never>;
created_at: ColumnType<string, string, never>;
}
export type SelectDocumentUpdate = Selectable<DocumentUpdateTable>;
export type CreateDocumentUpdate = Insertable<DocumentUpdateTable>;
export type UpdateDocumentUpdate = Updateable<DocumentUpdateTable>;
export interface WorkspaceDatabaseSchema { export interface WorkspaceDatabaseSchema {
users: UserTable; users: UserTable;
nodes: NodeTable; nodes: NodeTable;
@@ -214,11 +214,11 @@ export interface WorkspaceDatabaseSchema {
node_updates: NodeUpdateTable; node_updates: NodeUpdateTable;
node_reactions: NodeReactionTable; node_reactions: NodeReactionTable;
collaborations: CollaborationTable; collaborations: CollaborationTable;
files: FileTable;
mutations: MutationTable;
tombstones: TombstoneTable;
documents: DocumentTable; documents: DocumentTable;
document_updates: DocumentUpdateTable; document_updates: DocumentUpdateTable;
file_states: FileStateTable;
mutations: MutationTable;
tombstones: TombstoneTable;
cursors: CursorTable; cursors: CursorTable;
metadata: MetadataTable; metadata: MetadataTable;
} }

View File

@@ -9,7 +9,7 @@ import { SelectEmoji } from '@/main/databases/emojis';
import { SelectIcon } from '@/main/databases/icons'; import { SelectIcon } from '@/main/databases/icons';
import { SelectWorkspace } from '@/main/databases/account'; import { SelectWorkspace } from '@/main/databases/account';
import { import {
SelectFile, SelectFileState,
SelectMutation, SelectMutation,
SelectNode, SelectNode,
SelectUser, SelectUser,
@@ -20,7 +20,7 @@ import {
import { Account } from '@/shared/types/accounts'; import { Account } from '@/shared/types/accounts';
import { Server } from '@/shared/types/servers'; import { Server } from '@/shared/types/servers';
import { User } from '@/shared/types/users'; import { User } from '@/shared/types/users';
import { File } from '@/shared/types/files'; import { FileState } from '@/shared/types/files';
import { import {
Workspace, Workspace,
WorkspaceMetadata, WorkspaceMetadata,
@@ -136,29 +136,20 @@ export const mapNodeInteraction = (
}; };
}; };
export const mapFile = (row: SelectFile): File => { export const mapFileState = (row: SelectFileState): FileState => {
return { return {
id: row.id, id: row.id,
type: row.type, version: row.version,
parentId: row.parent_id,
rootId: row.root_id,
revision: row.revision,
name: row.name,
originalName: row.original_name,
extension: row.extension,
mimeType: row.mime_type,
size: row.size,
createdAt: row.created_at,
createdBy: row.created_by,
updatedAt: row.updated_at,
updatedBy: row.updated_by,
status: row.status,
downloadStatus: row.download_status, downloadStatus: row.download_status,
downloadProgress: row.download_progress, downloadProgress: row.download_progress,
downloadRetries: row.download_retries, downloadRetries: row.download_retries,
downloadStartedAt: row.download_started_at,
downloadCompletedAt: row.download_completed_at,
uploadStatus: row.upload_status, uploadStatus: row.upload_status,
uploadProgress: row.upload_progress, uploadProgress: row.upload_progress,
uploadRetries: row.upload_retries, uploadRetries: row.upload_retries,
uploadStartedAt: row.upload_started_at,
uploadCompletedAt: row.upload_completed_at,
}; };
}; };

View File

@@ -1,8 +1,9 @@
import { extractFileType } from '@colanode/core'; import { extractFileSubtype } from '@colanode/core';
import { import {
DeleteResult, DeleteResult,
InsertResult, InsertResult,
Kysely, Kysely,
sql,
Transaction, Transaction,
UpdateResult, UpdateResult,
} from 'kysely'; } from 'kysely';
@@ -100,7 +101,7 @@ export const getFileMetadata = (filePath: string): FileMetadata | null => {
} }
const stats = fs.statSync(filePath); const stats = fs.statSync(filePath);
const type = extractFileType(mimeType); const type = extractFileSubtype(mimeType);
return { return {
path: filePath, path: filePath,
@@ -162,8 +163,11 @@ export const fetchUserStorageUsed = async (
userId: string userId: string
): Promise<bigint> => { ): Promise<bigint> => {
const storageUsedRow = await database const storageUsedRow = await database
.selectFrom('files') .selectFrom('nodes')
.select(({ fn }) => [fn.sum('size').as('storage_used')]) .select(({ fn }) => [
fn.sum(sql`json_extract(attributes, '$.size')`).as('storage_used'),
])
.where('type', '=', 'file')
.where('created_by', '=', userId) .where('created_by', '=', userId)
.executeTakeFirst(); .executeTakeFirst();

View File

@@ -1,4 +1,4 @@
import { generateId, IdType, FileAttributes } from '@colanode/core'; import { generateId, IdType } from '@colanode/core';
import { MutationHandler } from '@/main/lib/types'; import { MutationHandler } from '@/main/lib/types';
import { import {
@@ -15,19 +15,9 @@ export class FileCreateMutationHandler
input: FileCreateMutationInput input: FileCreateMutationInput
): Promise<FileCreateMutationOutput> { ): Promise<FileCreateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId); const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const attributes: FileAttributes = {
type: 'file',
parentId: input.parentId,
};
const fileId = generateId(IdType.File); const fileId = generateId(IdType.File);
await workspace.nodes.createNode({ await workspace.files.createFile(fileId, input.parentId, input.filePath);
id: fileId,
attributes,
parentId: input.parentId,
});
await workspace.files.createFile(fileId, input.filePath);
return { return {
id: fileId, id: fileId,

View File

@@ -1,7 +1,7 @@
import { FileStatus } from '@colanode/core'; import { FileStatus } from '@colanode/core';
import { MutationHandler } from '@/main/lib/types'; import { MutationHandler } from '@/main/lib/types';
import { mapFile } from '@/main/lib/mappers'; import { mapFileState, mapNode } from '@/main/lib/mappers';
import { eventBus } from '@/shared/lib/event-bus'; import { eventBus } from '@/shared/lib/event-bus';
import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { MutationError, MutationErrorCode } from '@/shared/mutations';
import { import {
@@ -10,6 +10,7 @@ import {
} from '@/shared/mutations/files/file-download'; } from '@/shared/mutations/files/file-download';
import { DownloadStatus } from '@/shared/types/files'; import { DownloadStatus } from '@/shared/types/files';
import { WorkspaceMutationHandlerBase } from '@/main/mutations/workspace-mutation-handler-base'; import { WorkspaceMutationHandlerBase } from '@/main/mutations/workspace-mutation-handler-base';
import { LocalFileNode } from '@/shared/types/nodes';
export class FileDownloadMutationHandler export class FileDownloadMutationHandler
extends WorkspaceMutationHandlerBase extends WorkspaceMutationHandlerBase
@@ -20,48 +21,64 @@ export class FileDownloadMutationHandler
): Promise<FileDownloadMutationOutput> { ): Promise<FileDownloadMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId); const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const file = await workspace.database const node = await workspace.database
.selectFrom('files') .selectFrom('nodes')
.selectAll() .selectAll()
.where('id', '=', input.fileId) .where('id', '=', input.fileId)
.executeTakeFirst(); .executeTakeFirst();
if (!file) { if (!node) {
throw new MutationError( throw new MutationError(
MutationErrorCode.FileNotFound, MutationErrorCode.FileNotFound,
'The file you are trying to download does not exist.' 'The file you are trying to download does not exist.'
); );
} }
if (file.status !== FileStatus.Ready) { const file = mapNode(node) as LocalFileNode;
if (file.attributes.status !== FileStatus.Ready) {
throw new MutationError( throw new MutationError(
MutationErrorCode.FileNotReady, MutationErrorCode.FileNotReady,
'The file you are trying to download is not uploaded by the author yet.' 'The file you are trying to download is not uploaded by the author yet.'
); );
} }
const fileState = await workspace.database
.selectFrom('file_states')
.selectAll()
.where('id', '=', input.fileId)
.executeTakeFirst();
if ( if (
file.download_status === DownloadStatus.Completed || fileState?.download_status === DownloadStatus.Completed ||
file.download_status === DownloadStatus.Pending fileState?.download_status === DownloadStatus.Pending
) { ) {
return { return {
success: true, success: true,
}; };
} }
const updatedFile = await workspace.database const updatedFileState = await workspace.database
.updateTable('files') .insertInto('file_states')
.returningAll() .returningAll()
.set({ .values({
id: input.fileId,
version: file.attributes.version,
download_status: DownloadStatus.Pending, download_status: DownloadStatus.Pending,
download_progress: 0, download_progress: 0,
download_retries: 0, download_retries: 0,
updated_at: new Date().toISOString(), download_started_at: new Date().toISOString(),
}) })
.where('id', '=', input.fileId) .onConflict((oc) =>
oc.columns(['id']).doUpdateSet({
download_status: DownloadStatus.Pending,
download_progress: 0,
download_retries: 0,
download_started_at: new Date().toISOString(),
})
)
.executeTakeFirst(); .executeTakeFirst();
if (!updatedFile) { if (!updatedFileState) {
throw new MutationError( throw new MutationError(
MutationErrorCode.FileNotFound, MutationErrorCode.FileNotFound,
'The file you are trying to download does not exist.' 'The file you are trying to download does not exist.'
@@ -71,10 +88,10 @@ export class FileDownloadMutationHandler
workspace.files.triggerDownloads(); workspace.files.triggerDownloads();
eventBus.publish({ eventBus.publish({
type: 'file_updated', type: 'file_state_updated',
accountId: workspace.accountId, accountId: workspace.accountId,
workspaceId: workspace.id, workspaceId: workspace.id,
file: mapFile(updatedFile), fileState: mapFileState(updatedFileState),
}); });
return { return {

View File

@@ -1,6 +1,5 @@
import { import {
EditorNodeTypes, EditorNodeTypes,
FileAttributes,
generateId, generateId,
IdType, IdType,
MessageAttributes, MessageAttributes,
@@ -65,18 +64,7 @@ export class MessageCreateMutationHandler
}); });
for (const file of filesToCreate) { for (const file of filesToCreate) {
const fileAttributes: FileAttributes = { await workspace.files.createFile(file.id, messageId, file.path);
type: 'file',
parentId: messageId,
};
await workspace.nodes.createNode({
id: file.id,
attributes: fileAttributes,
parentId: messageId,
});
await workspace.files.createFile(file.id, file.path);
} }
return { return {

View File

@@ -1,22 +1,24 @@
import { ChangeCheckResult, QueryHandler } from '@/main/lib/types'; import { ChangeCheckResult, QueryHandler } from '@/main/lib/types';
import { mapFile } from '@/main/lib/mappers'; import { mapNode } from '@/main/lib/mappers';
import { FileListQueryInput } from '@/shared/queries/files/file-list'; import { FileListQueryInput } from '@/shared/queries/files/file-list';
import { Event } from '@/shared/types/events'; import { Event } from '@/shared/types/events';
import { File } from '@/shared/types/files'; import { LocalFileNode } from '@/shared/types/nodes';
import { WorkspaceQueryHandlerBase } from '@/main/queries/workspace-query-handler-base'; import { WorkspaceQueryHandlerBase } from '@/main/queries/workspace-query-handler-base';
export class FileListQueryHandler export class FileListQueryHandler
extends WorkspaceQueryHandlerBase extends WorkspaceQueryHandlerBase
implements QueryHandler<FileListQueryInput> implements QueryHandler<FileListQueryInput>
{ {
public async handleQuery(input: FileListQueryInput): Promise<File[]> { public async handleQuery(
input: FileListQueryInput
): Promise<LocalFileNode[]> {
return await this.fetchFiles(input); return await this.fetchFiles(input);
} }
public async checkForChanges( public async checkForChanges(
event: Event, event: Event,
input: FileListQueryInput, input: FileListQueryInput,
output: File[] output: LocalFileNode[]
): Promise<ChangeCheckResult<FileListQueryInput>> { ): Promise<ChangeCheckResult<FileListQueryInput>> {
if ( if (
event.type === 'workspace_deleted' && event.type === 'workspace_deleted' &&
@@ -30,10 +32,10 @@ export class FileListQueryHandler
} }
if ( if (
event.type === 'file_created' && event.type === 'node_created' &&
event.accountId === input.accountId && event.accountId === input.accountId &&
event.workspaceId === input.workspaceId && event.workspaceId === input.workspaceId &&
event.file.parentId === input.parentId event.node.parentId === input.parentId
) { ) {
const output = await this.handleQuery(input); const output = await this.handleQuery(input);
return { return {
@@ -43,16 +45,16 @@ export class FileListQueryHandler
} }
if ( if (
event.type === 'file_updated' && event.type === 'node_updated' &&
event.accountId === input.accountId && event.accountId === input.accountId &&
event.workspaceId === input.workspaceId && event.workspaceId === input.workspaceId &&
event.file.parentId === input.parentId event.node.parentId === input.parentId
) { ) {
const file = output.find((file) => file.id === event.file.id); const file = output.find((file) => file.id === event.node.id);
if (file) { if (file) {
const newResult = output.map((file) => { const newResult = output.map((file) => {
if (file.id === event.file.id) { if (file.id === event.node.id && event.node.type === 'file') {
return event.file; return event.node;
} }
return file; return file;
@@ -66,12 +68,12 @@ export class FileListQueryHandler
} }
if ( if (
event.type === 'file_deleted' && event.type === 'node_deleted' &&
event.accountId === input.accountId && event.accountId === input.accountId &&
event.workspaceId === input.workspaceId && event.workspaceId === input.workspaceId &&
event.file.parentId === input.parentId event.node.parentId === input.parentId
) { ) {
const file = output.find((file) => file.id === event.file.id); const file = output.find((file) => file.id === event.node.id);
if (file) { if (file) {
const output = await this.handleQuery(input); const output = await this.handleQuery(input);
return { return {
@@ -86,20 +88,22 @@ export class FileListQueryHandler
}; };
} }
private async fetchFiles(input: FileListQueryInput): Promise<File[]> { private async fetchFiles(
input: FileListQueryInput
): Promise<LocalFileNode[]> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId); const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const offset = (input.page - 1) * input.count; const offset = (input.page - 1) * input.count;
const files = await workspace.database const files = await workspace.database
.selectFrom('files') .selectFrom('nodes')
.selectAll() .selectAll()
.where('type', '=', 'file')
.where('parent_id', '=', input.parentId) .where('parent_id', '=', input.parentId)
.where('deleted_at', 'is', null)
.orderBy('id', 'asc') .orderBy('id', 'asc')
.limit(input.count) .limit(input.count)
.offset(offset) .offset(offset)
.execute(); .execute();
return files.map(mapFile); return files.map(mapNode) as LocalFileNode[];
} }
} }

View File

@@ -1,23 +1,25 @@
import { ChangeCheckResult, QueryHandler } from '@/main/lib/types'; import { ChangeCheckResult, QueryHandler } from '@/main/lib/types';
import { mapFile } from '@/main/lib/mappers'; import { mapFileState } from '@/main/lib/mappers';
import { FileGetQueryInput } from '@/shared/queries/files/file-get'; import { FileStateGetQueryInput } from '@/shared/queries/files/file-state-get';
import { Event } from '@/shared/types/events'; import { Event } from '@/shared/types/events';
import { File } from '@/shared/types/files'; import { FileState } from '@/shared/types/files';
import { WorkspaceQueryHandlerBase } from '@/main/queries/workspace-query-handler-base'; import { WorkspaceQueryHandlerBase } from '@/main/queries/workspace-query-handler-base';
export class FileGetQueryHandler export class FileStateGetQueryHandler
extends WorkspaceQueryHandlerBase extends WorkspaceQueryHandlerBase
implements QueryHandler<FileGetQueryInput> implements QueryHandler<FileStateGetQueryInput>
{ {
public async handleQuery(input: FileGetQueryInput): Promise<File | null> { public async handleQuery(
return await this.fetchFile(input); input: FileStateGetQueryInput
): Promise<FileState | null> {
return await this.fetchFileState(input);
} }
public async checkForChanges( public async checkForChanges(
event: Event, event: Event,
input: FileGetQueryInput, input: FileStateGetQueryInput,
_: File | null _: FileState | null
): Promise<ChangeCheckResult<FileGetQueryInput>> { ): Promise<ChangeCheckResult<FileStateGetQueryInput>> {
if ( if (
event.type === 'workspace_deleted' && event.type === 'workspace_deleted' &&
event.workspace.accountId === input.accountId && event.workspace.accountId === input.accountId &&
@@ -30,10 +32,10 @@ export class FileGetQueryHandler
} }
if ( if (
event.type === 'file_created' && event.type === 'file_state_updated' &&
event.accountId === input.accountId && event.accountId === input.accountId &&
event.workspaceId === input.workspaceId && event.workspaceId === input.workspaceId &&
event.file.id === input.id event.fileState.id === input.id
) { ) {
const output = await this.handleQuery(input); const output = await this.handleQuery(input);
return { return {
@@ -43,22 +45,10 @@ export class FileGetQueryHandler
} }
if ( if (
event.type === 'file_updated' && event.type === 'node_deleted' &&
event.accountId === input.accountId && event.accountId === input.accountId &&
event.workspaceId === input.workspaceId && event.workspaceId === input.workspaceId &&
event.file.id === input.id event.node.id === input.id
) {
return {
hasChanges: true,
result: event.file,
};
}
if (
event.type === 'file_deleted' &&
event.accountId === input.accountId &&
event.workspaceId === input.workspaceId &&
event.file.id === input.id
) { ) {
return { return {
hasChanges: true, hasChanges: true,
@@ -71,19 +61,21 @@ export class FileGetQueryHandler
}; };
} }
private async fetchFile(input: FileGetQueryInput): Promise<File | null> { private async fetchFileState(
input: FileStateGetQueryInput
): Promise<FileState | null> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId); const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const file = await workspace.database const fileState = await workspace.database
.selectFrom('files') .selectFrom('file_states')
.selectAll() .selectAll()
.where('id', '=', input.id) .where('id', '=', input.id)
.executeTakeFirst(); .executeTakeFirst();
if (!file || file.deleted_at) { if (!fileState) {
return null; return null;
} }
return mapFile(file); return mapFileState(fileState);
} }
} }

View File

@@ -7,7 +7,7 @@ import { EmojiCategoryListQueryHandler } from '@/main/queries/emojis/emoji-categ
import { EmojiSearchQueryHandler } from '@/main/queries/emojis/emoji-search'; import { EmojiSearchQueryHandler } from '@/main/queries/emojis/emoji-search';
import { EmojiGetBySkinIdQueryHandler } from '@/main/queries/emojis/emoji-get-by-skin-id'; import { EmojiGetBySkinIdQueryHandler } from '@/main/queries/emojis/emoji-get-by-skin-id';
import { FileListQueryHandler } from '@/main/queries/files/file-list'; import { FileListQueryHandler } from '@/main/queries/files/file-list';
import { FileGetQueryHandler } from '@/main/queries/files/file-get'; import { FileStateGetQueryHandler } from '@/main/queries/files/file-state-get';
import { FileMetadataGetQueryHandler } from '@/main/queries/files/file-metadata-get'; import { FileMetadataGetQueryHandler } from '@/main/queries/files/file-metadata-get';
import { IconListQueryHandler } from '@/main/queries/icons/icon-list'; import { IconListQueryHandler } from '@/main/queries/icons/icon-list';
import { IconSearchQueryHandler } from '@/main/queries/icons/icon-search'; import { IconSearchQueryHandler } from '@/main/queries/icons/icon-search';
@@ -71,7 +71,7 @@ export const queryHandlerMap: QueryHandlerMap = {
database_view_list: new DatabaseViewListQueryHandler(), database_view_list: new DatabaseViewListQueryHandler(),
record_search: new RecordSearchQueryHandler(), record_search: new RecordSearchQueryHandler(),
user_get: new UserGetQueryHandler(), user_get: new UserGetQueryHandler(),
file_get: new FileGetQueryHandler(), file_state_get: new FileStateGetQueryHandler(),
chat_list: new ChatListQueryHandler(), chat_list: new ChatListQueryHandler(),
space_list: new SpaceListQueryHandler(), space_list: new SpaceListQueryHandler(),
workspace_metadata_list: new WorkspaceMetadataListQueryHandler(), workspace_metadata_list: new WorkspaceMetadataListQueryHandler(),

View File

@@ -50,11 +50,6 @@ export class CollaborationService {
.where('root_id', '=', collaboration.nodeId) .where('root_id', '=', collaboration.nodeId)
.execute(); .execute();
await this.workspace.database
.deleteFrom('files')
.where('root_id', '=', collaboration.nodeId)
.execute();
await this.workspace.database await this.workspace.database
.deleteFrom('node_interactions') .deleteFrom('node_interactions')
.where('root_id', '=', collaboration.nodeId) .where('root_id', '=', collaboration.nodeId)

View File

@@ -1,12 +1,12 @@
import { import {
CompleteUploadOutput, CompleteUploadOutput,
CreateDownloadOutput, CreateDownloadOutput,
CreateFileMutationData,
CreateUploadOutput, CreateUploadOutput,
FileAttributes,
FileStatus, FileStatus,
IdType, IdType,
SyncFileData,
createDebugger, createDebugger,
extractFileSubtype,
generateId, generateId,
} from '@colanode/core'; } from '@colanode/core';
import axios from 'axios'; import axios from 'axios';
@@ -22,14 +22,15 @@ import {
getWorkspaceFilesDirectoryPath, getWorkspaceFilesDirectoryPath,
getWorkspaceTempFilesDirectoryPath, getWorkspaceTempFilesDirectoryPath,
} from '@/main/lib/utils'; } from '@/main/lib/utils';
import { mapFile, mapNode } from '@/main/lib/mappers'; import { mapFileState, mapNode } from '@/main/lib/mappers';
import { eventBus } from '@/shared/lib/event-bus'; import { eventBus } from '@/shared/lib/event-bus';
import { DownloadStatus, UploadStatus } from '@/shared/types/files'; import { DownloadStatus, UploadStatus } from '@/shared/types/files';
import { WorkspaceService } from '@/main/services/workspaces/workspace-service'; import { WorkspaceService } from '@/main/services/workspaces/workspace-service';
import { EventLoop } from '@/main/lib/event-loop'; import { EventLoop } from '@/main/lib/event-loop';
import { SelectFile } from '@/main/databases/workspace'; import { SelectFileState, SelectNode } from '@/main/databases/workspace';
import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { MutationError, MutationErrorCode } from '@/shared/mutations';
import { formatBytes } from '@/shared/lib/files'; import { formatBytes } from '@/shared/lib/files';
import { LocalFileNode } from '@/shared/types/nodes';
const UPLOAD_RETRIES_LIMIT = 10; const UPLOAD_RETRIES_LIMIT = 10;
const DOWNLOAD_RETRIES_LIMIT = 10; const DOWNLOAD_RETRIES_LIMIT = 10;
@@ -94,7 +95,11 @@ export class FileService {
this.cleanupEventLoop.start(); this.cleanupEventLoop.start();
} }
public async createFile(id: string, path: string): Promise<void> { public async createFile(
id: string,
parentId: string,
path: string
): Promise<void> {
const metadata = getFileMetadata(path); const metadata = getFileMetadata(path);
if (!metadata) { if (!metadata) {
throw new MutationError( throw new MutationError(
@@ -128,8 +133,8 @@ export class FileService {
); );
} }
const node = await fetchNode(this.workspace.database, id); const node = await fetchNode(this.workspace.database, parentId);
if (!node || node.type !== 'file') { if (!node) {
throw new MutationError( throw new MutationError(
MutationErrorCode.NodeNotFound, MutationErrorCode.NodeNotFound,
'There was an error while creating the file. Please make sure you have access to this node.' 'There was an error while creating the file. Please make sure you have access to this node.'
@@ -138,79 +143,70 @@ export class FileService {
this.copyFileToWorkspace(path, id, metadata.extension); this.copyFileToWorkspace(path, id, metadata.extension);
const mutationData: CreateFileMutationData = { const attributes: FileAttributes = {
id, type: 'file',
type: metadata.type, subtype: extractFileSubtype(metadata.mimeType),
parentId: node.parent_id!, parentId: parentId,
rootId: node.root_id,
name: metadata.name, name: metadata.name,
originalName: metadata.name, originalName: metadata.name,
extension: metadata.extension, extension: metadata.extension,
mimeType: metadata.mimeType, mimeType: metadata.mimeType,
size: metadata.size, size: metadata.size,
createdAt: new Date().toISOString(), status: FileStatus.Pending,
version: generateId(IdType.Version),
}; };
const createdFile = await this.workspace.database await this.workspace.nodes.createNode({
.transaction() id: id,
.execute(async (tx) => { attributes: attributes,
const createdFile = await tx parentId: parentId,
.insertInto('files') });
.returningAll()
.values({
id,
type: metadata.type,
parent_id: node.parent_id!,
root_id: node.root_id,
name: metadata.name,
original_name: metadata.name,
mime_type: metadata.mimeType,
size: metadata.size,
extension: metadata.extension,
created_at: new Date().toISOString(),
created_by: this.workspace.userId,
status: FileStatus.Pending,
revision: 0n,
download_status: DownloadStatus.Completed,
download_progress: 100,
download_retries: 0,
upload_status: UploadStatus.Pending,
upload_progress: 0,
upload_retries: 0,
})
.executeTakeFirst();
if (!createdFile) { const createdFileState = await this.workspace.database
throw new Error('Failed to create file.'); .insertInto('file_states')
} .returningAll()
.values({
id: id,
version: attributes.version,
download_progress: 100,
download_status: DownloadStatus.Completed,
download_completed_at: new Date().toISOString(),
upload_progress: 0,
upload_status: UploadStatus.Pending,
upload_retries: 0,
upload_started_at: new Date().toISOString(),
})
.executeTakeFirst();
await tx if (!createdFileState) {
.insertInto('mutations') throw new MutationError(
.values({ MutationErrorCode.FileCreateFailed,
id: generateId(IdType.Mutation), 'Failed to create file state'
type: 'create_file', );
data: JSON.stringify(mutationData),
created_at: new Date().toISOString(),
retries: 0,
})
.execute();
return createdFile;
});
if (createdFile) {
this.workspace.mutations.triggerSync();
eventBus.publish({
type: 'file_created',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
file: mapFile(createdFile),
});
} }
eventBus.publish({
type: 'file_state_updated',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
fileState: mapFileState(createdFileState),
});
this.triggerUploads();
} }
public copyFileToWorkspace( public async deleteFile(node: SelectNode): Promise<void> {
const file = mapNode(node);
if (file.type !== 'file') {
return;
}
const filePath = this.buildFilePath(file.id, file.attributes.extension);
fs.rmSync(filePath, { force: true });
}
private copyFileToWorkspace(
filePath: string, filePath: string,
fileId: string, fileId: string,
fileExtension: string fileExtension: string
@@ -233,13 +229,6 @@ export class FileService {
} }
} }
public deleteFile(id: string, extension: string): void {
const filePath = this.buildFilePath(id, extension);
this.debug(`Deleting file ${filePath}`);
fs.rmSync(filePath, { force: true });
}
public triggerUploads(): void { public triggerUploads(): void {
this.uploadsEventLoop.trigger(); this.uploadsEventLoop.trigger();
} }
@@ -262,7 +251,7 @@ export class FileService {
this.debug(`Uploading files for workspace ${this.workspace.id}`); this.debug(`Uploading files for workspace ${this.workspace.id}`);
const uploads = await this.workspace.database const uploads = await this.workspace.database
.selectFrom('files') .selectFrom('file_states')
.selectAll() .selectAll()
.where('upload_status', '=', UploadStatus.Pending) .where('upload_status', '=', UploadStatus.Pending)
.execute(); .execute();
@@ -276,71 +265,77 @@ export class FileService {
} }
} }
private async uploadFile(file: SelectFile): Promise<void> { private async uploadFile(state: SelectFileState): Promise<void> {
if (file.upload_retries >= UPLOAD_RETRIES_LIMIT) { if (state.upload_retries && state.upload_retries >= UPLOAD_RETRIES_LIMIT) {
this.debug( this.debug(
`File ${file.id} upload retries limit reached, marking as failed` `File ${state.id} upload retries limit reached, marking as failed`
); );
const updatedFile = await this.workspace.database const updatedFileState = await this.workspace.database
.updateTable('files') .updateTable('file_states')
.returningAll() .returningAll()
.set({ .set({
upload_status: UploadStatus.Failed, upload_status: UploadStatus.Failed,
updated_at: new Date().toISOString(), upload_retries: state.upload_retries + 1,
}) })
.where('id', '=', file.id) .where('id', '=', state.id)
.executeTakeFirst(); .executeTakeFirst();
if (updatedFile) { if (updatedFileState) {
eventBus.publish({ eventBus.publish({
type: 'file_updated', type: 'file_state_updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
file: mapFile(updatedFile), fileState: mapFileState(updatedFileState),
}); });
} }
return; return;
} }
if (file.revision === BigInt(0)) { const node = await this.workspace.database
.selectFrom('nodes')
.selectAll()
.where('id', '=', state.id)
.executeTakeFirst();
if (!node) {
return;
}
const file = mapNode(node) as LocalFileNode;
if (node.server_revision === BigInt(0)) {
// file is not synced with the server, we need to wait for the sync to complete // file is not synced with the server, we need to wait for the sync to complete
return; return;
} }
if (file.status === FileStatus.Ready) { if (file.attributes.status === FileStatus.Ready) {
const updatedFile = await this.workspace.database const updatedFileState = await this.workspace.database
.updateTable('files') .updateTable('file_states')
.returningAll() .returningAll()
.set({ .set({
upload_status: UploadStatus.Completed, upload_status: UploadStatus.Completed,
upload_progress: 100, upload_progress: 100,
updated_at: new Date().toISOString(), upload_completed_at: new Date().toISOString(),
}) })
.where('id', '=', file.id) .where('id', '=', file.id)
.executeTakeFirst(); .executeTakeFirst();
if (updatedFile) { if (updatedFileState) {
eventBus.publish({ eventBus.publish({
type: 'file_updated', type: 'file_state_updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
file: mapFile(updatedFile), fileState: mapFileState(updatedFileState),
}); });
} }
return; return;
} }
const filePath = this.buildFilePath(file.id, file.extension); const filePath = this.buildFilePath(file.id, file.attributes.extension);
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
await this.workspace.database
.deleteFrom('files')
.where('id', '=', file.id)
.execute();
this.debug(`File ${file.id} not found, deleting from database`); this.debug(`File ${file.id} not found, deleting from database`);
return; return;
} }
@@ -360,11 +355,13 @@ export class FileService {
let lastProgress = 0; let lastProgress = 0;
await axios.put(presignedUrl, fileStream, { await axios.put(presignedUrl, fileStream, {
headers: { headers: {
'Content-Type': file.mime_type, 'Content-Type': file.attributes.mimeType,
'Content-Length': file.size, 'Content-Length': file.attributes.size,
}, },
onUploadProgress: async (progressEvent) => { onUploadProgress: async (progressEvent) => {
const progress = Math.round((progressEvent.loaded / file.size) * 100); const progress = Math.round(
(progressEvent.loaded / file.attributes.size) * 100
);
if (progress >= lastProgress) { if (progress >= lastProgress) {
return; return;
@@ -372,25 +369,24 @@ export class FileService {
lastProgress = progress; lastProgress = progress;
const updatedFile = await this.workspace.database const updatedFileState = await this.workspace.database
.updateTable('files') .updateTable('file_states')
.returningAll() .returningAll()
.set({ .set({
upload_progress: progress, upload_progress: progress,
updated_at: new Date().toISOString(),
}) })
.where('id', '=', file.id) .where('id', '=', file.id)
.executeTakeFirst(); .executeTakeFirst();
if (!updatedFile) { if (!updatedFileState) {
return; return;
} }
eventBus.publish({ eventBus.publish({
type: 'file_updated', type: 'file_state_updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
file: mapFile(updatedFile), fileState: mapFileState(updatedFileState),
}); });
}, },
}); });
@@ -400,41 +396,41 @@ export class FileService {
{} {}
); );
const finalFile = await this.workspace.database const finalFileState = await this.workspace.database
.updateTable('files') .updateTable('file_states')
.returningAll() .returningAll()
.set({ .set({
upload_status: UploadStatus.Completed, upload_status: UploadStatus.Completed,
upload_progress: 100, upload_progress: 100,
updated_at: new Date().toISOString(), upload_completed_at: new Date().toISOString(),
}) })
.where('id', '=', file.id) .where('id', '=', file.id)
.executeTakeFirst(); .executeTakeFirst();
if (finalFile) { if (finalFileState) {
eventBus.publish({ eventBus.publish({
type: 'file_updated', type: 'file_state_updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
file: mapFile(finalFile), fileState: mapFileState(finalFileState),
}); });
} }
this.debug(`File ${file.id} uploaded successfully`); this.debug(`File ${file.id} uploaded successfully`);
} catch { } catch {
const updatedFile = await this.workspace.database const updatedFileState = await this.workspace.database
.updateTable('files') .updateTable('file_states')
.returningAll() .returningAll()
.set((eb) => ({ upload_retries: eb('upload_retries', '+', 1) })) .set((eb) => ({ upload_retries: eb('upload_retries', '+', 1) }))
.where('id', '=', file.id) .where('id', '=', file.id)
.executeTakeFirst(); .executeTakeFirst();
if (updatedFile) { if (updatedFileState) {
eventBus.publish({ eventBus.publish({
type: 'file_updated', type: 'file_state_updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
file: mapFile(updatedFile), fileState: mapFileState(updatedFileState),
}); });
} }
} }
@@ -448,7 +444,7 @@ export class FileService {
this.debug(`Downloading files for workspace ${this.workspace.id}`); this.debug(`Downloading files for workspace ${this.workspace.id}`);
const downloads = await this.workspace.database const downloads = await this.workspace.database
.selectFrom('files') .selectFrom('file_states')
.selectAll() .selectAll()
.where('download_status', '=', DownloadStatus.Pending) .where('download_status', '=', DownloadStatus.Pending)
.execute(); .execute();
@@ -462,41 +458,73 @@ export class FileService {
} }
} }
private async downloadFile(file: SelectFile): Promise<void> { private async downloadFile(fileState: SelectFileState): Promise<void> {
if (file.download_retries >= DOWNLOAD_RETRIES_LIMIT) { if (
fileState.download_retries &&
fileState.download_retries >= DOWNLOAD_RETRIES_LIMIT
) {
this.debug( this.debug(
`File ${file.id} download retries limit reached, marking as failed` `File ${fileState.id} download retries limit reached, marking as failed`
); );
await this.workspace.database const updatedFileState = await this.workspace.database
.updateTable('files') .updateTable('file_states')
.returningAll()
.set({ .set({
download_status: DownloadStatus.Failed, download_status: DownloadStatus.Failed,
updated_at: new Date().toISOString(), download_retries: fileState.download_retries + 1,
}) })
.where('id', '=', file.id) .where('id', '=', fileState.id)
.execute(); .executeTakeFirst();
if (updatedFileState) {
eventBus.publish({
type: 'file_state_updated',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
fileState: mapFileState(updatedFileState),
});
}
return;
} }
const filePath = this.buildFilePath(file.id, file.extension); const node = await this.workspace.database
.selectFrom('nodes')
.selectAll()
.where('id', '=', fileState.id)
.executeTakeFirst();
if (!node) {
return;
}
const file = mapNode(node) as LocalFileNode;
if (node.server_revision === BigInt(0)) {
// file is not synced with the server, we need to wait for the sync to complete
return;
}
const filePath = this.buildFilePath(file.id, file.attributes.extension);
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
const updatedFile = await this.workspace.database const updatedFileState = await this.workspace.database
.updateTable('files') .updateTable('file_states')
.returningAll() .returningAll()
.set({ .set({
download_status: DownloadStatus.Completed, download_status: DownloadStatus.Completed,
download_progress: 100, download_progress: 100,
updated_at: new Date().toISOString(), download_completed_at: new Date().toISOString(),
}) })
.where('id', '=', file.id) .where('id', '=', fileState.id)
.executeTakeFirst(); .executeTakeFirst();
if (updatedFile) { if (updatedFileState) {
eventBus.publish({ eventBus.publish({
type: 'file_updated', type: 'file_state_updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
file: mapFile(updatedFile), fileState: mapFileState(updatedFileState),
}); });
} }
@@ -519,7 +547,7 @@ export class FileService {
responseType: 'stream', responseType: 'stream',
onDownloadProgress: async (progressEvent) => { onDownloadProgress: async (progressEvent) => {
const progress = Math.round( const progress = Math.round(
(progressEvent.loaded / file.size) * 100 (progressEvent.loaded / file.attributes.size) * 100
); );
if (progress <= lastProgress) { if (progress <= lastProgress) {
@@ -528,22 +556,21 @@ export class FileService {
lastProgress = progress; lastProgress = progress;
const updatedFile = await this.workspace.database const updatedFileState = await this.workspace.database
.updateTable('files') .updateTable('file_states')
.returningAll() .returningAll()
.set({ .set({
download_progress: progress, download_progress: progress,
updated_at: new Date().toISOString(),
}) })
.where('id', '=', file.id) .where('id', '=', fileState.id)
.executeTakeFirst(); .executeTakeFirst();
if (updatedFile) { if (updatedFileState) {
eventBus.publish({ eventBus.publish({
type: 'file_updated', type: 'file_state_updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
file: mapFile(updatedFile), fileState: mapFileState(updatedFileState),
}); });
} }
}, },
@@ -552,39 +579,39 @@ export class FileService {
response.data.pipe(fileStream); response.data.pipe(fileStream);
}); });
const updatedFile = await this.workspace.database const updatedFileState = await this.workspace.database
.updateTable('files') .updateTable('file_states')
.returningAll() .returningAll()
.set({ .set({
download_status: DownloadStatus.Completed, download_status: DownloadStatus.Completed,
download_progress: 100, download_progress: 100,
updated_at: new Date().toISOString(), download_completed_at: new Date().toISOString(),
}) })
.where('id', '=', file.id) .where('id', '=', fileState.id)
.executeTakeFirst(); .executeTakeFirst();
if (updatedFile) { if (updatedFileState) {
eventBus.publish({ eventBus.publish({
type: 'file_updated', type: 'file_state_updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
file: mapFile(updatedFile), fileState: mapFileState(updatedFileState),
}); });
} }
} catch { } catch {
const updatedFile = await this.workspace.database const updatedFileState = await this.workspace.database
.updateTable('files') .updateTable('file_states')
.returningAll() .returningAll()
.set((eb) => ({ download_retries: eb('download_retries', '+', 1) })) .set((eb) => ({ download_retries: eb('download_retries', '+', 1) }))
.where('id', '=', file.id) .where('id', '=', fileState.id)
.executeTakeFirst(); .executeTakeFirst();
if (updatedFile) { if (updatedFileState) {
eventBus.publish({ eventBus.publish({
type: 'file_updated', type: 'file_state_updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
file: mapFile(updatedFile), fileState: mapFileState(updatedFileState),
}); });
} }
} }
@@ -604,15 +631,15 @@ export class FileService {
} }
const fileIds = Object.keys(fileIdMap); const fileIds = Object.keys(fileIdMap);
const files = await this.workspace.database const fileStates = await this.workspace.database
.selectFrom('files') .selectFrom('file_states')
.select(['id']) .select(['id'])
.where('id', 'in', fileIds) .where('id', 'in', fileIds)
.execute(); .execute();
for (const fileId of fileIds) { for (const fileId of fileIds) {
const file = files.find((f) => f.id === fileId); const fileState = fileStates.find((f) => f.id === fileId);
if (!file) { if (fileState) {
continue; continue;
} }
@@ -647,160 +674,6 @@ export class FileService {
} }
} }
public async syncServerFile(file: SyncFileData): Promise<void> {
const existingFile = await this.workspace.database
.selectFrom('files')
.selectAll()
.where('id', '=', file.id)
.executeTakeFirst();
const revision = BigInt(file.revision);
if (existingFile) {
if (existingFile.revision === revision) {
this.debug(`Server file ${file.id} is already synced`);
return;
}
const updatedFile = await this.workspace.database
.updateTable('files')
.returningAll()
.set({
name: file.name,
original_name: file.originalName,
mime_type: file.mimeType,
extension: file.extension,
size: file.size,
parent_id: file.parentId,
root_id: file.rootId,
status: file.status,
type: file.type,
updated_at: file.updatedAt ?? undefined,
updated_by: file.updatedBy ?? undefined,
revision,
})
.where('id', '=', file.id)
.executeTakeFirst();
if (!updatedFile) {
return;
}
eventBus.publish({
type: 'file_updated',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
file: mapFile(updatedFile),
});
this.triggerUploads();
this.triggerDownloads();
this.debug(`Server file ${file.id} has been synced`);
return;
}
const createdFile = await this.workspace.database
.insertInto('files')
.returningAll()
.values({
id: file.id,
revision,
type: file.type,
parent_id: file.parentId,
root_id: file.rootId,
name: file.name,
original_name: file.originalName,
mime_type: file.mimeType,
extension: file.extension,
size: file.size,
created_at: file.createdAt,
created_by: file.createdBy,
updated_at: file.updatedAt,
updated_by: file.updatedBy,
status: file.status,
download_progress: 0,
download_retries: 0,
upload_progress: 0,
upload_retries: 0,
download_status: DownloadStatus.None,
upload_status: UploadStatus.None,
})
.executeTakeFirst();
if (!createdFile) {
return;
}
eventBus.publish({
type: 'file_created',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
file: mapFile(createdFile),
});
this.debug(`Server file ${file.id} has been synced`);
}
public async revertFileCreate(fileId: string) {
const deletedFile = await this.workspace.database
.deleteFrom('files')
.returningAll()
.where('id', '=', fileId)
.executeTakeFirst();
if (!deletedFile) {
return;
}
const deletedNode = await this.workspace.database
.deleteFrom('nodes')
.returningAll()
.where('id', '=', deletedFile.parent_id)
.executeTakeFirst();
if (!deletedNode) {
return;
}
await this.workspace.database
.deleteFrom('node_states')
.where('id', '=', deletedNode.id)
.execute();
await this.workspace.database
.deleteFrom('node_interactions')
.where('node_id', '=', deletedNode.id)
.execute();
await this.workspace.database
.deleteFrom('node_reactions')
.where('node_id', '=', deletedNode.id)
.execute();
const filePath = path.join(
this.filesDir,
`${fileId}${deletedFile.extension}`
);
if (fs.existsSync(filePath)) {
fs.rmSync(filePath, { force: true });
}
eventBus.publish({
type: 'file_deleted',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
file: mapFile(deletedFile),
});
eventBus.publish({
type: 'node_deleted',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
node: mapNode(deletedNode),
});
}
private buildFilePath(id: string, extension: string): string { private buildFilePath(id: string, extension: string): string {
return path.join(this.filesDir, `${id}${extension}`); return path.join(this.filesDir, `${id}${extension}`);
} }

View File

@@ -143,9 +143,7 @@ export class MutationService {
for (const mutationRow of invalidMutations) { for (const mutationRow of invalidMutations) {
const mutation = mapMutation(mutationRow); const mutation = mapMutation(mutationRow);
if (mutation.type === 'create_file') { if (mutation.type === 'create_node') {
await this.workspace.files.revertFileCreate(mutation.id);
} else if (mutation.type === 'create_node') {
await this.workspace.nodes.revertNodeCreate(mutation.data); await this.workspace.nodes.revertNodeCreate(mutation.data);
} else if (mutation.type === 'update_node') { } else if (mutation.type === 'update_node') {
await this.workspace.nodes.revertNodeUpdate(mutation.data); await this.workspace.nodes.revertNodeUpdate(mutation.data);

View File

@@ -16,7 +16,7 @@ import {
import { decodeState, encodeState, YDoc } from '@colanode/crdt'; import { decodeState, encodeState, YDoc } from '@colanode/crdt';
import { fetchNodeTree } from '@/main/lib/utils'; import { fetchNodeTree } from '@/main/lib/utils';
import { mapFile, mapNode } from '@/main/lib/mappers'; import { mapNode } from '@/main/lib/mappers';
import { eventBus } from '@/shared/lib/event-bus'; import { eventBus } from '@/shared/lib/event-bus';
import { WorkspaceService } from '@/main/services/workspaces/workspace-service'; import { WorkspaceService } from '@/main/services/workspaces/workspace-service';
import { SelectNode } from '@/main/databases/workspace'; import { SelectNode } from '@/main/databases/workspace';
@@ -587,7 +587,7 @@ export class NodeService {
`Applying server delete transaction ${tombstone.id} for node ${tombstone.id}` `Applying server delete transaction ${tombstone.id} for node ${tombstone.id}`
); );
const { deletedNode, deletedFile } = await this.workspace.database const { deletedNode } = await this.workspace.database
.transaction() .transaction()
.execute(async (trx) => { .execute(async (trx) => {
const deletedNode = await trx const deletedNode = await trx
@@ -616,12 +616,6 @@ export class NodeService {
.where('node_id', '=', tombstone.id) .where('node_id', '=', tombstone.id)
.execute(); .execute();
const deletedFile = await trx
.deleteFrom('files')
.returningAll()
.where('id', '=', tombstone.id)
.executeTakeFirst();
await trx await trx
.deleteFrom('tombstones') .deleteFrom('tombstones')
.where('id', '=', tombstone.id) .where('id', '=', tombstone.id)
@@ -637,19 +631,9 @@ export class NodeService {
.where('document_id', '=', tombstone.id) .where('document_id', '=', tombstone.id)
.execute(); .execute();
return { deletedNode, deletedFile }; return { deletedNode };
}); });
if (deletedFile) {
this.workspace.files.deleteFile(deletedFile.id, deletedFile.extension);
eventBus.publish({
type: 'file_deleted',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
file: mapFile(deletedFile),
});
}
if (deletedNode) { if (deletedNode) {
eventBus.publish({ eventBus.publish({
type: 'node_deleted', type: 'node_deleted',
@@ -702,6 +686,10 @@ export class NodeService {
.execute(); .execute();
}); });
if (node.type === 'file') {
await this.workspace.files.deleteFile(node);
}
eventBus.publish({ eventBus.publish({
type: 'node_deleted', type: 'node_deleted',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,

View File

@@ -5,7 +5,6 @@ import {
SyncNodesInput, SyncNodesInput,
SyncNodeInteractionsInput, SyncNodeInteractionsInput,
SyncNodeReactionsInput, SyncNodeReactionsInput,
SyncFilesInput,
SyncNodeTombstonesInput, SyncNodeTombstonesInput,
SyncNodeInteractionData, SyncNodeInteractionData,
SyncNodeReactionData, SyncNodeReactionData,
@@ -13,7 +12,6 @@ import {
SyncNodeData, SyncNodeData,
SyncUserData, SyncUserData,
SyncCollaborationData, SyncCollaborationData,
SyncFileData,
SyncDocumentUpdatesInput, SyncDocumentUpdatesInput,
SyncDocumentUpdateData, SyncDocumentUpdateData,
} from '@colanode/core'; } from '@colanode/core';
@@ -28,7 +26,6 @@ interface RootSynchronizers {
nodeInteractions: Synchronizer<SyncNodeInteractionsInput>; nodeInteractions: Synchronizer<SyncNodeInteractionsInput>;
nodeReactions: Synchronizer<SyncNodeReactionsInput>; nodeReactions: Synchronizer<SyncNodeReactionsInput>;
nodeTombstones: Synchronizer<SyncNodeTombstonesInput>; nodeTombstones: Synchronizer<SyncNodeTombstonesInput>;
files: Synchronizer<SyncFilesInput>;
documentUpdates: Synchronizer<SyncDocumentUpdatesInput>; documentUpdates: Synchronizer<SyncDocumentUpdatesInput>;
} }
@@ -39,7 +36,6 @@ type SyncHandlers = {
nodeInteractions: (data: SyncNodeInteractionData) => Promise<void>; nodeInteractions: (data: SyncNodeInteractionData) => Promise<void>;
nodeReactions: (data: SyncNodeReactionData) => Promise<void>; nodeReactions: (data: SyncNodeReactionData) => Promise<void>;
nodeTombstones: (data: SyncNodeTombstoneData) => Promise<void>; nodeTombstones: (data: SyncNodeTombstoneData) => Promise<void>;
files: (data: SyncFileData) => Promise<void>;
documentUpdates: (data: SyncDocumentUpdateData) => Promise<void>; documentUpdates: (data: SyncDocumentUpdateData) => Promise<void>;
}; };
@@ -76,7 +72,6 @@ export class SyncService {
nodeTombstones: this.workspace.nodes.syncServerNodeDelete.bind( nodeTombstones: this.workspace.nodes.syncServerNodeDelete.bind(
this.workspace.nodes this.workspace.nodes
), ),
files: this.workspace.files.syncServerFile.bind(this.workspace.files),
documentUpdates: this.workspace.documents.syncServerDocumentUpdate.bind( documentUpdates: this.workspace.documents.syncServerDocumentUpdate.bind(
this.workspace.documents this.workspace.documents
), ),
@@ -149,7 +144,6 @@ export class SyncService {
rootSynchronizers.nodeInteractions.destroy(); rootSynchronizers.nodeInteractions.destroy();
rootSynchronizers.nodeReactions.destroy(); rootSynchronizers.nodeReactions.destroy();
rootSynchronizers.nodeTombstones.destroy(); rootSynchronizers.nodeTombstones.destroy();
rootSynchronizers.files.destroy();
rootSynchronizers.documentUpdates.destroy(); rootSynchronizers.documentUpdates.destroy();
} }
@@ -187,12 +181,6 @@ export class SyncService {
`${rootId}_node_tombstones`, `${rootId}_node_tombstones`,
this.syncHandlers.nodeTombstones this.syncHandlers.nodeTombstones
), ),
files: new Synchronizer(
this.workspace,
{ type: 'files', rootId },
`${rootId}_files`,
this.syncHandlers.files
),
documentUpdates: new Synchronizer( documentUpdates: new Synchronizer(
this.workspace, this.workspace,
{ type: 'document_updates', rootId }, { type: 'document_updates', rootId },

View File

@@ -2,6 +2,7 @@ import { FilePreview } from '@/renderer/components/files/file-preview';
import { useWorkspace } from '@/renderer/contexts/workspace'; import { useWorkspace } from '@/renderer/contexts/workspace';
import { useLayout } from '@/renderer/contexts/layout'; import { useLayout } from '@/renderer/contexts/layout';
import { useQuery } from '@/renderer/hooks/use-query'; import { useQuery } from '@/renderer/hooks/use-query';
import { LocalFileNode } from '@/shared/types/nodes';
interface FileBlockProps { interface FileBlockProps {
id: string; id: string;
@@ -12,8 +13,8 @@ export const FileBlock = ({ id }: FileBlockProps) => {
const layout = useLayout(); const layout = useLayout();
const { data } = useQuery({ const { data } = useQuery({
type: 'file_get', type: 'node_get',
id, nodeId: id,
accountId: workspace.accountId, accountId: workspace.accountId,
workspaceId: workspace.id, workspaceId: workspace.id,
}); });
@@ -22,6 +23,8 @@ export const FileBlock = ({ id }: FileBlockProps) => {
return null; return null;
} }
const file = data as LocalFileNode;
return ( return (
<div <div
className="flex h-72 max-h-72 max-w-128 w-full cursor-pointer overflow-hidden rounded-md p-2 hover:bg-gray-100" className="flex h-72 max-h-72 max-w-128 w-full cursor-pointer overflow-hidden rounded-md p-2 hover:bg-gray-100"
@@ -29,7 +32,7 @@ export const FileBlock = ({ id }: FileBlockProps) => {
layout.previewLeft(id, true); layout.previewLeft(id, true);
}} }}
> >
<FilePreview file={data} /> <FilePreview file={file} />
</div> </div>
); );
}; };

View File

@@ -4,7 +4,6 @@ import { FilePreview } from '@/renderer/components/files/file-preview';
import { FileSidebar } from '@/renderer/components/files/file-sidebar'; import { FileSidebar } from '@/renderer/components/files/file-sidebar';
import { Button } from '@/renderer/components/ui/button'; import { Button } from '@/renderer/components/ui/button';
import { useWorkspace } from '@/renderer/contexts/workspace'; import { useWorkspace } from '@/renderer/contexts/workspace';
import { useQuery } from '@/renderer/hooks/use-query';
import { LocalFileNode } from '@/shared/types/nodes'; import { LocalFileNode } from '@/shared/types/nodes';
interface FileBodyProps { interface FileBodyProps {
@@ -13,20 +12,6 @@ interface FileBodyProps {
export const FileBody = ({ file }: FileBodyProps) => { export const FileBody = ({ file }: FileBodyProps) => {
const workspace = useWorkspace(); const workspace = useWorkspace();
const { data, isPending } = useQuery({
type: 'file_get',
id: file.id,
accountId: workspace.accountId,
workspaceId: workspace.id,
});
if (isPending) {
return <p className="text-sm text-muted-foreground">Loading...</p>;
}
if (!data) {
return <p className="text-sm text-muted-foreground">Not found</p>;
}
return ( return (
<div className="flex h-full max-h-full w-full flex-row items-center gap-2"> <div className="flex h-full max-h-full w-full flex-row items-center gap-2">
@@ -40,8 +25,8 @@ export const FileBody = ({ file }: FileBodyProps) => {
type: 'file_open', type: 'file_open',
accountId: workspace.accountId, accountId: workspace.accountId,
workspaceId: workspace.id, workspaceId: workspace.id,
fileId: data.id, fileId: file.id,
extension: data.extension, extension: file.attributes.extension,
}) })
} }
> >
@@ -49,11 +34,11 @@ export const FileBody = ({ file }: FileBodyProps) => {
</Button> </Button>
</div> </div>
<div className="flex w-full max-w-full flex-grow items-center justify-center overflow-hidden p-10"> <div className="flex w-full max-w-full flex-grow items-center justify-center overflow-hidden p-10">
<FilePreview file={data} /> <FilePreview file={file} />
</div> </div>
</div> </div>
<div className="h-full w-72 min-w-72 overflow-hidden border-l border-gray-100 p-2 pl-3"> <div className="h-full w-72 min-w-72 overflow-hidden border-l border-gray-100 p-2 pl-3">
<FileSidebar file={data} /> <FileSidebar file={file} />
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,4 @@
import { FileThumbnail } from '@/renderer/components/files/file-thumbnail'; import { FileThumbnail } from '@/renderer/components/files/file-thumbnail';
import { useQuery } from '@/renderer/hooks/use-query';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { LocalFileNode } from '@/shared/types/nodes'; import { LocalFileNode } from '@/shared/types/nodes';
interface FileBreadcrumbItemProps { interface FileBreadcrumbItemProps {
@@ -8,26 +6,13 @@ interface FileBreadcrumbItemProps {
} }
export const FileBreadcrumbItem = ({ file }: FileBreadcrumbItemProps) => { export const FileBreadcrumbItem = ({ file }: FileBreadcrumbItemProps) => {
const workspace = useWorkspace();
const { data } = useQuery({
type: 'file_get',
id: file.id,
accountId: workspace.accountId,
workspaceId: workspace.id,
});
if (!data) {
return null;
}
return ( return (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<FileThumbnail <FileThumbnail
file={data} file={file}
className="size-4 overflow-hidden rounded object-contain" className="size-4 overflow-hidden rounded object-contain"
/> />
<span>{data.name}</span> <span>{file.attributes.name}</span>
</div> </div>
); );
}; };

View File

@@ -1,6 +1,7 @@
import { FileThumbnail } from '@/renderer/components/files/file-thumbnail'; import { FileThumbnail } from '@/renderer/components/files/file-thumbnail';
import { useWorkspace } from '@/renderer/contexts/workspace'; import { useWorkspace } from '@/renderer/contexts/workspace';
import { useQuery } from '@/renderer/hooks/use-query'; import { useQuery } from '@/renderer/hooks/use-query';
import { LocalFileNode } from '@/shared/types/nodes';
interface FileContainerTabProps { interface FileContainerTabProps {
fileId: string; fileId: string;
@@ -10,8 +11,8 @@ export const FileContainerTab = ({ fileId }: FileContainerTabProps) => {
const workspace = useWorkspace(); const workspace = useWorkspace();
const { data, isPending } = useQuery({ const { data, isPending } = useQuery({
type: 'file_get', type: 'node_get',
id: fileId, nodeId: fileId,
accountId: workspace.accountId, accountId: workspace.accountId,
workspaceId: workspace.id, workspaceId: workspace.id,
}); });
@@ -20,7 +21,7 @@ export const FileContainerTab = ({ fileId }: FileContainerTabProps) => {
return <p className="text-sm text-muted-foreground">Loading...</p>; return <p className="text-sm text-muted-foreground">Loading...</p>;
} }
const file = data; const file = data as LocalFileNode;
if (!file) { if (!file) {
return <p className="text-sm text-muted-foreground">Not found</p>; return <p className="text-sm text-muted-foreground">Not found</p>;
} }
@@ -31,7 +32,7 @@ export const FileContainerTab = ({ fileId }: FileContainerTabProps) => {
file={file} file={file}
className="size-4 overflow-hidden rounded object-contain" className="size-4 overflow-hidden rounded object-contain"
/> />
<span>{file.name}</span> <span>{file.attributes.name}</span>
</div> </div>
); );
}; };

View File

@@ -3,24 +3,28 @@ import { Download } from 'lucide-react';
import { Spinner } from '@/renderer/components/ui/spinner'; import { Spinner } from '@/renderer/components/ui/spinner';
import { useWorkspace } from '@/renderer/contexts/workspace'; import { useWorkspace } from '@/renderer/contexts/workspace';
import { toast } from '@/renderer/hooks/use-toast'; import { toast } from '@/renderer/hooks/use-toast';
import { DownloadStatus, File } from '@/shared/types/files'; import { DownloadStatus, FileState } from '@/shared/types/files';
import { formatBytes } from '@/shared/lib/files'; import { formatBytes } from '@/shared/lib/files';
import { LocalFileNode } from '@/shared/types/nodes';
interface FileDownloadProps { interface FileDownloadProps {
file: File; file: LocalFileNode;
state: FileState | null | undefined;
} }
export const FileDownload = ({ file }: FileDownloadProps) => { export const FileDownload = ({ file, state }: FileDownloadProps) => {
const workspace = useWorkspace(); const workspace = useWorkspace();
const isDownloading = file.downloadStatus === DownloadStatus.Pending; const isDownloading = state?.downloadStatus === DownloadStatus.Pending;
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
{isDownloading ? ( {isDownloading ? (
<div className="flex flex-col items-center gap-3 text-muted-foreground"> <div className="flex flex-col items-center gap-3 text-muted-foreground">
<Spinner className="size-8" /> <Spinner className="size-8" />
<p className="text-sm">Downloading file ({file.downloadProgress}%)</p> <p className="text-sm">
Downloading file ({state?.downloadProgress}%)
</p>
</div> </div>
) : ( ) : (
<div <div
@@ -50,7 +54,8 @@ export const FileDownload = ({ file }: FileDownloadProps) => {
File is not downloaded in your device. Click to download. File is not downloaded in your device. Click to download.
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{formatBytes(file.size)} - {file.mimeType} {formatBytes(file.attributes.size)} -{' '}
{file.attributes.mimeType.split('/')[1]}
</p> </p>
</div> </div>
)} )}

View File

@@ -6,28 +6,42 @@ import { FilePreviewOther } from '@/renderer/components/files/previews/file-prev
import { FilePreviewVideo } from '@/renderer/components/files/previews/file-preview-video'; import { FilePreviewVideo } from '@/renderer/components/files/previews/file-preview-video';
import { useWorkspace } from '@/renderer/contexts/workspace'; import { useWorkspace } from '@/renderer/contexts/workspace';
import { getFileUrl } from '@/shared/lib/files'; import { getFileUrl } from '@/shared/lib/files';
import { File } from '@/shared/types/files'; import { LocalFileNode } from '@/shared/types/nodes';
import { useQuery } from '@/renderer/hooks/use-query';
interface FilePreviewProps { interface FilePreviewProps {
file: File; file: LocalFileNode;
} }
export const FilePreview = ({ file }: FilePreviewProps) => { export const FilePreview = ({ file }: FilePreviewProps) => {
const workspace = useWorkspace(); const workspace = useWorkspace();
if (file.downloadProgress !== 100) { const { data, isPending } = useQuery({
return <FileDownload file={file} />; type: 'file_state_get',
id: file.id,
accountId: workspace.accountId,
workspaceId: workspace.id,
});
if (isPending) {
return null;
}
if (data?.downloadProgress !== 100) {
return <FileDownload file={file} state={data} />;
} }
const url = getFileUrl( const url = getFileUrl(
workspace.accountId, workspace.accountId,
workspace.id, workspace.id,
file.id, file.id,
file.extension file.attributes.extension
); );
return match(file.type) return match(file.attributes.subtype)
.with('image', () => <FilePreviewImage url={url} name={file.name} />) .with('image', () => (
<FilePreviewImage url={url} name={file.attributes.name} />
))
.with('video', () => <FilePreviewVideo url={url} />) .with('video', () => <FilePreviewVideo url={url} />)
.otherwise(() => <FilePreviewOther mimeType={file.mimeType} />); .otherwise(() => <FilePreviewOther mimeType={file.attributes.mimeType} />);
}; };

View File

@@ -6,10 +6,10 @@ import { FileThumbnail } from '@/renderer/components/files/file-thumbnail';
import { useWorkspace } from '@/renderer/contexts/workspace'; import { useWorkspace } from '@/renderer/contexts/workspace';
import { useQuery } from '@/renderer/hooks/use-query'; import { useQuery } from '@/renderer/hooks/use-query';
import { formatBytes } from '@/shared/lib/files'; import { formatBytes } from '@/shared/lib/files';
import { File } from '@/shared/types/files'; import { LocalFileNode } from '@/shared/types/nodes';
interface FileSidebarProps { interface FileSidebarProps {
file: File; file: LocalFileNode;
} }
const FileMeta = ({ title, value }: { title: string; value: string }) => { const FileMeta = ({ title, value }: { title: string; value: string }) => {
@@ -41,16 +41,16 @@ export const FileSidebar = ({ file }: FileSidebarProps) => {
/> />
<div <div
className="line-clamp-3 break-words text-base font-medium" className="line-clamp-3 break-words text-base font-medium"
title={file.name} title={file.attributes.name}
> >
{file.name} {file.attributes.name}
</div> </div>
</div> </div>
<div className="mt-5 flex flex-col gap-4"> <div className="mt-5 flex flex-col gap-4">
<FileMeta title="Name" value={file.name} /> <FileMeta title="Name" value={file.attributes.name} />
<FileMeta title="Type" value={file.mimeType} /> <FileMeta title="Type" value={file.attributes.mimeType} />
<FileMeta title="Size" value={formatBytes(file.size)} /> <FileMeta title="Size" value={formatBytes(file.attributes.size)} />
<FileMeta title="Created at" value={formatDate(file.createdAt)} /> <FileMeta title="Created at" value={formatDate(file.createdAt)} />
{user && ( {user && (

View File

@@ -1,32 +1,40 @@
import { FileIcon } from '@/renderer/components/files/file-icon'; import { FileIcon } from '@/renderer/components/files/file-icon';
import { useWorkspace } from '@/renderer/contexts/workspace'; import { useWorkspace } from '@/renderer/contexts/workspace';
import { useQuery } from '@/renderer/hooks/use-query';
import { getFileUrl } from '@/shared/lib/files'; import { getFileUrl } from '@/shared/lib/files';
import { cn } from '@/shared/lib/utils'; import { cn } from '@/shared/lib/utils';
import { File } from '@/shared/types/files'; import { LocalFileNode } from '@/shared/types/nodes';
interface FileThumbnailProps { interface FileThumbnailProps {
file: File; file: LocalFileNode;
className?: string; className?: string;
} }
export const FileThumbnail = ({ file, className }: FileThumbnailProps) => { export const FileThumbnail = ({ file, className }: FileThumbnailProps) => {
const workspace = useWorkspace(); const workspace = useWorkspace();
if (file.type === 'image' && file.downloadProgress === 100) { const { data } = useQuery({
type: 'file_state_get',
id: file.id,
accountId: workspace.accountId,
workspaceId: workspace.id,
});
if (file.attributes.subtype === 'image' && data?.downloadProgress === 100) {
const url = getFileUrl( const url = getFileUrl(
workspace.accountId, workspace.accountId,
workspace.id, workspace.id,
file.id, file.id,
file.extension file.attributes.extension
); );
return ( return (
<img <img
src={url} src={url}
alt={file.name} alt={file.attributes.name}
className={cn('object-contain object-center', className)} className={cn('object-contain object-center', className)}
/> />
); );
} }
return <FileIcon mimeType={file.mimeType} className="size-10" />; return <FileIcon mimeType={file.attributes.mimeType} className="size-10" />;
}; };

View File

@@ -1,10 +1,10 @@
import { File } from '@/shared/types/files'; import { LocalFileNode } from '@/shared/types/nodes';
import { FileContextMenu } from '@/renderer/components/files/file-context-menu'; import { FileContextMenu } from '@/renderer/components/files/file-context-menu';
import { FileThumbnail } from '@/renderer/components/files/file-thumbnail'; import { FileThumbnail } from '@/renderer/components/files/file-thumbnail';
import { GridItem } from '@/renderer/components/folders/grids/grid-item'; import { GridItem } from '@/renderer/components/folders/grids/grid-item';
interface GridFileProps { interface GridFileProps {
file: File; file: LocalFileNode;
} }
export const GridFile = ({ file }: GridFileProps) => { export const GridFile = ({ file }: GridFileProps) => {
@@ -16,9 +16,9 @@ export const GridFile = ({ file }: GridFileProps) => {
</div> </div>
<p <p
className="line-clamp-2 w-full break-words text-center text-xs text-foreground/80" className="line-clamp-2 w-full break-words text-center text-xs text-foreground/80"
title={file.name} title={file.attributes.name}
> >
{file.name} {file.attributes.name}
</p> </p>
</GridItem> </GridItem>
</FileContextMenu> </FileContextMenu>

View File

@@ -1,11 +1,11 @@
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
import { File } from '@/shared/types/files'; import { LocalFileNode } from '@/shared/types/nodes';
interface FolderContext { interface FolderContext {
id: string; id: string;
name: string; name: string;
files: File[]; files: LocalFileNode[];
onClick: (event: React.MouseEvent<HTMLElement>, id: string) => void; onClick: (event: React.MouseEvent<HTMLElement>, id: string) => void;
onDoubleClick: (event: React.MouseEvent<HTMLElement>, id: string) => void; onDoubleClick: (event: React.MouseEvent<HTMLElement>, id: string) => void;
onMove: (entryId: string, targetId: string) => void; onMove: (entryId: string, targetId: string) => void;

View File

@@ -1,17 +0,0 @@
import { File } from '@/shared/types/files';
export type FileGetQueryInput = {
type: 'file_get';
id: string;
accountId: string;
workspaceId: string;
};
declare module '@/shared/queries' {
interface QueryMap {
file_get: {
input: FileGetQueryInput;
output: File | null;
};
}
}

View File

@@ -1,4 +1,4 @@
import { File } from '@/shared/types/files'; import { LocalFileNode } from '@/shared/types/nodes';
export type FileListQueryInput = { export type FileListQueryInput = {
type: 'file_list'; type: 'file_list';
@@ -13,7 +13,7 @@ declare module '@/shared/queries' {
interface QueryMap { interface QueryMap {
file_list: { file_list: {
input: FileListQueryInput; input: FileListQueryInput;
output: File[]; output: LocalFileNode[];
}; };
} }
} }

View File

@@ -0,0 +1,17 @@
import { FileState } from '@/shared/types/files';
export type FileStateGetQueryInput = {
type: 'file_state_get';
id: string;
accountId: string;
workspaceId: string;
};
declare module '@/shared/queries' {
interface QueryMap {
file_state_get: {
input: FileStateGetQueryInput;
output: FileState | null;
};
}
}

View File

@@ -5,7 +5,7 @@ import { Account } from '@/shared/types/accounts';
import { Server } from '@/shared/types/servers'; import { Server } from '@/shared/types/servers';
import { Workspace, WorkspaceMetadata } from '@/shared/types/workspaces'; import { Workspace, WorkspaceMetadata } from '@/shared/types/workspaces';
import { User } from '@/shared/types/users'; import { User } from '@/shared/types/users';
import { File } from '@/shared/types/files'; import { FileState } from '@/shared/types/files';
import { LocalNode, NodeInteraction, NodeReaction } from '@/shared/types/nodes'; import { LocalNode, NodeInteraction, NodeReaction } from '@/shared/types/nodes';
export type UserCreatedEvent = { export type UserCreatedEvent = {
@@ -71,25 +71,11 @@ export type NodeReactionDeletedEvent = {
nodeReaction: NodeReaction; nodeReaction: NodeReaction;
}; };
export type FileCreatedEvent = { export type FileStateUpdatedEvent = {
type: 'file_created'; type: 'file_state_updated';
accountId: string; accountId: string;
workspaceId: string; workspaceId: string;
file: File; fileState: FileState;
};
export type FileUpdatedEvent = {
type: 'file_updated';
accountId: string;
workspaceId: string;
file: File;
};
export type FileDeletedEvent = {
type: 'file_deleted';
accountId: string;
workspaceId: string;
file: File;
}; };
export type AccountCreatedEvent = { export type AccountCreatedEvent = {
@@ -235,9 +221,7 @@ export type Event =
| WorkspaceDeletedEvent | WorkspaceDeletedEvent
| ServerCreatedEvent | ServerCreatedEvent
| ServerUpdatedEvent | ServerUpdatedEvent
| FileCreatedEvent | FileStateUpdatedEvent
| FileUpdatedEvent
| FileDeletedEvent
| QueryResultUpdatedEvent | QueryResultUpdatedEvent
| RadarDataUpdatedEvent | RadarDataUpdatedEvent
| ServerAvailabilityChangedEvent | ServerAvailabilityChangedEvent

View File

@@ -1,4 +1,4 @@
import { FileStatus, FileType } from '@colanode/core'; import { FileSubtype } from '@colanode/core';
export type FileMetadata = { export type FileMetadata = {
path: string; path: string;
@@ -6,31 +6,22 @@ export type FileMetadata = {
extension: string; extension: string;
name: string; name: string;
size: number; size: number;
type: FileType; type: FileSubtype;
}; };
export type File = { export type FileState = {
id: string; id: string;
type: FileType; version: string;
parentId: string; downloadStatus: DownloadStatus | null;
rootId: string; downloadProgress: number | null;
revision: bigint; downloadRetries: number | null;
name: string; downloadStartedAt: string | null;
originalName: string; downloadCompletedAt: string | null;
extension: string; uploadStatus: UploadStatus | null;
mimeType: string; uploadProgress: number | null;
size: number; uploadRetries: number | null;
createdAt: string; uploadStartedAt: string | null;
createdBy: string; uploadCompletedAt: string | null;
updatedAt: string | null;
updatedBy: string | null;
downloadStatus: DownloadStatus;
downloadProgress: number;
downloadRetries: number;
uploadStatus: UploadStatus;
uploadProgress: number;
uploadRetries: number;
status: FileStatus;
}; };
export enum DownloadStatus { export enum DownloadStatus {

View File

@@ -9,47 +9,51 @@ import {
import { GetObjectCommand } from '@aws-sdk/client-s3'; import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { database } from '@/data/database'; import { fetchNodeTree, mapNode } from '@/lib/nodes';
import { fetchNode, mapNode } from '@/lib/nodes';
import { fileS3 } from '@/data/storage'; import { fileS3 } from '@/data/storage';
import { ResponseBuilder } from '@/lib/response-builder'; import { ResponseBuilder } from '@/lib/response-builder';
import { configuration } from '@/lib/configuration'; import { configuration } from '@/lib/configuration';
import { buildFilePath } from '@/lib/files';
export const fileDownloadGetHandler = async ( export const fileDownloadGetHandler = async (
req: Request, req: Request,
res: Response res: Response
): Promise<void> => { ): Promise<void> => {
const workspaceId = req.params.workspaceId as string;
const fileId = req.params.fileId as string; const fileId = req.params.fileId as string;
const file = await database const tree = await fetchNodeTree(fileId);
.selectFrom('files') if (tree.length === 0) {
.selectAll()
.where('id', '=', fileId)
.executeTakeFirst();
if (!file) {
return ResponseBuilder.badRequest(res, { return ResponseBuilder.badRequest(res, {
code: ApiErrorCode.FileNotFound, code: ApiErrorCode.FileNotFound,
message: 'File not found.', message: 'File not found.',
}); });
} }
if (file.status !== FileStatus.Ready) { const nodes = tree.map((node) => mapNode(node));
const file = nodes[nodes.length - 1]!;
if (!file || file.id !== fileId) {
return ResponseBuilder.badRequest(res, {
code: ApiErrorCode.FileNotFound,
message: 'File not found.',
});
}
if (file.type !== 'file') {
return ResponseBuilder.badRequest(res, {
code: ApiErrorCode.FileNotFound,
message: 'This node is not a file.',
});
}
if (file.attributes.status !== FileStatus.Ready) {
return ResponseBuilder.badRequest(res, { return ResponseBuilder.badRequest(res, {
code: ApiErrorCode.FileNotReady, code: ApiErrorCode.FileNotReady,
message: 'File is not ready to be downloaded.', message: 'File is not ready to be downloaded.',
}); });
} }
const root = await fetchNode(file.root_id); const role = extractNodeRole(nodes, res.locals.user.id);
if (!root) {
return ResponseBuilder.badRequest(res, {
code: ApiErrorCode.RootNotFound,
message: 'Root not found.',
});
}
const role = extractNodeRole(mapNode(root), res.locals.user.id);
if (role === null || !hasNodeRole(role, 'viewer')) { if (role === null || !hasNodeRole(role, 'viewer')) {
return ResponseBuilder.forbidden(res, { return ResponseBuilder.forbidden(res, {
code: ApiErrorCode.FileNoAccess, code: ApiErrorCode.FileNoAccess,
@@ -58,7 +62,7 @@ export const fileDownloadGetHandler = async (
} }
//generate presigned url for download //generate presigned url for download
const path = `files/${file.workspace_id}/${file.id}${file.extension}`; const path = buildFilePath(workspaceId, file.id, file.attributes);
const command = new GetObjectCommand({ const command = new GetObjectCommand({
Bucket: configuration.fileS3.bucketName, Bucket: configuration.fileS3.bucketName,
Key: path, Key: path,

View File

@@ -4,9 +4,10 @@ import { FileStatus, ApiErrorCode } from '@colanode/core';
import { database } from '@/data/database'; import { database } from '@/data/database';
import { fileS3 } from '@/data/storage'; import { fileS3 } from '@/data/storage';
import { eventBus } from '@/lib/event-bus';
import { ResponseBuilder } from '@/lib/response-builder'; import { ResponseBuilder } from '@/lib/response-builder';
import { configuration } from '@/lib/configuration'; import { configuration } from '@/lib/configuration';
import { mapNode, updateNode } from '@/lib/nodes';
import { buildFilePath } from '@/lib/files';
export const fileUploadCompleteHandler = async ( export const fileUploadCompleteHandler = async (
req: Request, req: Request,
@@ -15,47 +16,55 @@ export const fileUploadCompleteHandler = async (
const workspaceId = req.params.workspaceId as string; const workspaceId = req.params.workspaceId as string;
const fileId = req.params.fileId as string; const fileId = req.params.fileId as string;
const file = await database const node = await database
.selectFrom('files') .selectFrom('nodes')
.selectAll() .selectAll()
.where('id', '=', fileId) .where('id', '=', fileId)
.executeTakeFirst(); .executeTakeFirst();
if (!file) { if (!node) {
return ResponseBuilder.badRequest(res, { return ResponseBuilder.badRequest(res, {
code: ApiErrorCode.FileNotFound, code: ApiErrorCode.FileNotFound,
message: 'File not found.', message: 'File not found.',
}); });
} }
if (file.created_by !== res.locals.user.id) { if (node.created_by !== res.locals.user.id) {
return ResponseBuilder.forbidden(res, { return ResponseBuilder.forbidden(res, {
code: ApiErrorCode.FileOwnerMismatch, code: ApiErrorCode.FileOwnerMismatch,
message: 'You cannot complete this file upload.', message: 'You cannot complete this file upload.',
}); });
} }
if (file.workspace_id !== workspaceId) { if (node.workspace_id !== workspaceId) {
return ResponseBuilder.badRequest(res, { return ResponseBuilder.badRequest(res, {
code: ApiErrorCode.WorkspaceMismatch, code: ApiErrorCode.WorkspaceMismatch,
message: 'File does not belong to this workspace.', message: 'File does not belong to this workspace.',
}); });
} }
if (file.status === FileStatus.Ready) { const file = mapNode(node);
if (file.type !== 'file') {
return ResponseBuilder.badRequest(res, {
code: ApiErrorCode.FileNotFound,
message: 'This node is not a file.',
});
}
if (file.attributes.status === FileStatus.Ready) {
return ResponseBuilder.success(res, { return ResponseBuilder.success(res, {
success: true, success: true,
}); });
} }
if (file.status === FileStatus.Error) { if (file.attributes.status === FileStatus.Error) {
return ResponseBuilder.badRequest(res, { return ResponseBuilder.badRequest(res, {
code: ApiErrorCode.FileError, code: ApiErrorCode.FileError,
message: 'File has failed to upload.', message: 'File has failed to upload.',
}); });
} }
const path = `files/${file.workspace_id}/${file.id}${file.extension}`; const path = buildFilePath(workspaceId, file.id, file.attributes);
// check if the file exists in the bucket // check if the file exists in the bucket
const command = new HeadObjectCommand({ const command = new HeadObjectCommand({
Bucket: configuration.fileS3.bucketName, Bucket: configuration.fileS3.bucketName,
@@ -66,7 +75,7 @@ export const fileUploadCompleteHandler = async (
const headObject = await fileS3.send(command); const headObject = await fileS3.send(command);
// Verify file size matches expected size // Verify file size matches expected size
if (headObject.ContentLength !== file.size) { if (headObject.ContentLength !== file.attributes.size) {
return ResponseBuilder.badRequest(res, { return ResponseBuilder.badRequest(res, {
code: ApiErrorCode.FileSizeMismatch, code: ApiErrorCode.FileSizeMismatch,
message: 'Uploaded file size does not match expected size', message: 'Uploaded file size does not match expected size',
@@ -74,7 +83,7 @@ export const fileUploadCompleteHandler = async (
} }
// Verify mime type matches expected type // Verify mime type matches expected type
if (headObject.ContentType !== file.mime_type) { if (headObject.ContentType !== file.attributes.mimeType) {
return ResponseBuilder.badRequest(res, { return ResponseBuilder.badRequest(res, {
code: ApiErrorCode.FileMimeTypeMismatch, code: ApiErrorCode.FileMimeTypeMismatch,
message: 'Uploaded file type does not match expected type', message: 'Uploaded file type does not match expected type',
@@ -87,31 +96,27 @@ export const fileUploadCompleteHandler = async (
}); });
} }
const updatedFile = await database const result = await updateNode({
.updateTable('files') nodeId: fileId,
.returningAll() userId: res.locals.user.id,
.set({ workspaceId: workspaceId,
status: FileStatus.Ready, updater(attributes) {
updated_by: res.locals.user.id, if (attributes.type !== 'file') {
updated_at: new Date(), throw new Error('Node is not a file');
}) }
.where('id', '=', fileId)
.executeTakeFirst();
if (!updatedFile) { attributes.status = FileStatus.Ready;
return attributes;
},
});
if (result === null) {
return ResponseBuilder.internalError(res, { return ResponseBuilder.internalError(res, {
code: ApiErrorCode.FileUploadCompleteFailed, code: ApiErrorCode.FileUploadCompleteFailed,
message: 'Failed to complete file upload.', message: 'Failed to complete file upload.',
}); });
} }
eventBus.publish({
type: 'file_updated',
fileId: updatedFile.id,
rootId: updatedFile.root_id,
workspaceId: updatedFile.workspace_id,
});
return ResponseBuilder.success(res, { return ResponseBuilder.success(res, {
success: true, success: true,
}); });

View File

@@ -11,6 +11,8 @@ import { database } from '@/data/database';
import { fileS3 } from '@/data/storage'; import { fileS3 } from '@/data/storage';
import { ResponseBuilder } from '@/lib/response-builder'; import { ResponseBuilder } from '@/lib/response-builder';
import { configuration } from '@/lib/configuration'; import { configuration } from '@/lib/configuration';
import { mapNode } from '@/lib/nodes';
import { buildFilePath } from '@/lib/files';
export const fileUploadInitHandler = async ( export const fileUploadInitHandler = async (
req: Request, req: Request,
@@ -19,20 +21,20 @@ export const fileUploadInitHandler = async (
const workspaceId = req.params.workspaceId as string; const workspaceId = req.params.workspaceId as string;
const input = req.body as CreateUploadInput; const input = req.body as CreateUploadInput;
const file = await database const node = await database
.selectFrom('files') .selectFrom('nodes')
.selectAll() .selectAll()
.where('id', '=', input.fileId) .where('id', '=', input.fileId)
.executeTakeFirst(); .executeTakeFirst();
if (!file) { if (!node) {
return ResponseBuilder.notFound(res, { return ResponseBuilder.notFound(res, {
code: ApiErrorCode.FileNotFound, code: ApiErrorCode.FileNotFound,
message: 'File not found.', message: 'File not found.',
}); });
} }
if (file.created_by !== res.locals.user.id) { if (node.created_by !== res.locals.user.id) {
return ResponseBuilder.forbidden(res, { return ResponseBuilder.forbidden(res, {
code: ApiErrorCode.FileOwnerMismatch, code: ApiErrorCode.FileOwnerMismatch,
message: 'You do not have access to this file.', message: 'You do not have access to this file.',
@@ -40,12 +42,20 @@ export const fileUploadInitHandler = async (
} }
//generate presigned url for upload //generate presigned url for upload
const path = `files/${workspaceId}/${input.fileId}${file.extension}`; const file = mapNode(node);
if (file.type !== 'file') {
return ResponseBuilder.badRequest(res, {
code: ApiErrorCode.FileNotFound,
message: 'This node is not a file.',
});
}
const path = buildFilePath(workspaceId, input.fileId, file.attributes);
const command = new PutObjectCommand({ const command = new PutObjectCommand({
Bucket: configuration.fileS3.bucketName, Bucket: configuration.fileS3.bucketName,
Key: path, Key: path,
ContentLength: file.size, ContentLength: file.attributes.size,
ContentType: file.mime_type, ContentType: file.attributes.mimeType,
}); });
const expiresIn = 60 * 60 * 4; // 4 hours const expiresIn = 60 * 60 * 4; // 4 hours

View File

@@ -1,66 +0,0 @@
import { sql, Migration } from 'kysely';
export const createFilesTable: Migration = {
up: async (db) => {
await sql`
CREATE SEQUENCE IF NOT EXISTS files_revision_sequence
START WITH 1000000000
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
`.execute(db);
await db.schema
.createTable('files')
.addColumn('id', 'varchar(30)', (col) => col.notNull().primaryKey())
.addColumn('type', 'varchar(30)', (col) => col.notNull())
.addColumn('parent_id', 'varchar(30)', (col) => col.notNull())
.addColumn('entry_id', 'varchar(30)', (col) => col.notNull())
.addColumn('root_id', 'varchar(30)', (col) => col.notNull())
.addColumn('workspace_id', 'varchar(30)', (col) => col.notNull())
.addColumn('name', 'varchar(256)', (col) => col.notNull())
.addColumn('original_name', 'varchar(256)', (col) => col.notNull())
.addColumn('mime_type', 'varchar(256)', (col) => col.notNull())
.addColumn('extension', 'varchar(256)', (col) => col.notNull())
.addColumn('size', 'integer', (col) => col.notNull())
.addColumn('created_at', 'timestamptz', (col) => col.notNull())
.addColumn('created_by', 'varchar(30)', (col) => col.notNull())
.addColumn('updated_at', 'timestamptz')
.addColumn('updated_by', 'varchar(30)')
.addColumn('status', 'integer', (col) => col.notNull())
.addColumn('revision', 'bigint', (col) =>
col.notNull().defaultTo(sql`nextval('files_revision_sequence')`)
)
.execute();
await db.schema
.createIndex('files_root_id_revision_idx')
.on('files')
.columns(['root_id', 'revision'])
.execute();
await sql`
CREATE OR REPLACE FUNCTION update_file_revision() RETURNS TRIGGER AS $$
BEGIN
NEW.revision = nextval('files_revision_sequence');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_update_file_revision
BEFORE UPDATE ON files
FOR EACH ROW
EXECUTE FUNCTION update_file_revision();
`.execute(db);
},
down: async (db) => {
await sql`
DROP TRIGGER IF EXISTS trg_update_file_revision ON files;
DROP FUNCTION IF EXISTS update_file_revision();
`.execute(db);
await db.schema.dropTable('files').execute();
await sql`DROP SEQUENCE IF EXISTS files_revision_sequence`.execute(db);
},
};

View File

@@ -10,9 +10,8 @@ import { createNodeInteractionsTable } from './00007-create-node-interactions-ta
import { createNodeTombstonesTable } from './00008-create-node-tombstones-table'; import { createNodeTombstonesTable } from './00008-create-node-tombstones-table';
import { createNodePathsTable } from './00009-create-node-paths-table'; import { createNodePathsTable } from './00009-create-node-paths-table';
import { createCollaborationsTable } from './00010-create-collaborations-table'; import { createCollaborationsTable } from './00010-create-collaborations-table';
import { createFilesTable } from './00011-create-files-table'; import { createDocumentsTable } from './00011-create-documents-table';
import { createDocumentsTable } from './00012-create-documents-table'; import { createDocumentUpdatesTable } from './00012-create-document-updates-table';
import { createDocumentUpdatesTable } from './00013-create-document-updates-table';
export const databaseMigrations: Record<string, Migration> = { export const databaseMigrations: Record<string, Migration> = {
'00001_create_accounts_table': createAccountsTable, '00001_create_accounts_table': createAccountsTable,
@@ -25,7 +24,6 @@ export const databaseMigrations: Record<string, Migration> = {
'00008_create_node_tombstones_table': createNodeTombstonesTable, '00008_create_node_tombstones_table': createNodeTombstonesTable,
'00009_create_node_paths_table': createNodePathsTable, '00009_create_node_paths_table': createNodePathsTable,
'00010_create_collaborations_table': createCollaborationsTable, '00010_create_collaborations_table': createCollaborationsTable,
'00011_create_files_table': createFilesTable, '00011_create_documents_table': createDocumentsTable,
'00012_create_documents_table': createDocumentsTable, '00012_create_document_updates_table': createDocumentUpdatesTable,
'00013_create_document_updates_table': createDocumentUpdatesTable,
}; };

View File

@@ -1,6 +1,4 @@
import { import {
FileStatus,
FileType,
NodeAttributes, NodeAttributes,
NodeRole, NodeRole,
NodeType, NodeType,
@@ -187,29 +185,6 @@ export type SelectCollaboration = Selectable<CollaborationTable>;
export type CreateCollaboration = Insertable<CollaborationTable>; export type CreateCollaboration = Insertable<CollaborationTable>;
export type UpdateCollaboration = Updateable<CollaborationTable>; export type UpdateCollaboration = Updateable<CollaborationTable>;
interface FileTable {
id: ColumnType<string, string, never>;
type: ColumnType<FileType, FileType, FileType>;
parent_id: ColumnType<string, string, never>;
root_id: ColumnType<string, string, never>;
workspace_id: ColumnType<string, string, never>;
revision: ColumnType<bigint, never, never>;
name: ColumnType<string, string, never>;
original_name: ColumnType<string, string, never>;
mime_type: ColumnType<string, string, never>;
extension: ColumnType<string, string, never>;
size: ColumnType<number, number, never>;
created_at: ColumnType<Date, Date, never>;
created_by: ColumnType<string, string, never>;
updated_at: ColumnType<Date | null, Date | null, Date | null>;
updated_by: ColumnType<string | null, string | null, string | null>;
status: ColumnType<FileStatus, FileStatus, FileStatus>;
}
export type SelectFile = Selectable<FileTable>;
export type CreateFile = Insertable<FileTable>;
export type UpdateFile = Updateable<FileTable>;
interface DocumentTable { interface DocumentTable {
id: ColumnType<string, string, never>; id: ColumnType<string, string, never>;
type: ColumnType<DocumentType, DocumentType, DocumentType>; type: ColumnType<DocumentType, DocumentType, DocumentType>;
@@ -257,7 +232,6 @@ export interface DatabaseSchema {
node_paths: NodePathTable; node_paths: NodePathTable;
node_tombstones: NodeTombstoneTable; node_tombstones: NodeTombstoneTable;
collaborations: CollaborationTable; collaborations: CollaborationTable;
files: FileTable;
documents: DocumentTable; documents: DocumentTable;
document_updates: DocumentUpdateTable; document_updates: DocumentUpdateTable;
} }

View File

@@ -0,0 +1,9 @@
import { FileAttributes } from '@colanode/core';
export const buildFilePath = (
workspaceId: string,
fileId: string,
fileAttributes: FileAttributes
) => {
return `files/${workspaceId}/${fileId}_${fileAttributes.version}${fileAttributes.extension}`;
};

View File

@@ -27,7 +27,6 @@ import { NodeSynchronizer } from '@/synchronizers/nodes';
import { NodeReactionSynchronizer } from '@/synchronizers/node-reactions'; import { NodeReactionSynchronizer } from '@/synchronizers/node-reactions';
import { NodeTombstoneSynchronizer } from '@/synchronizers/node-tombstones'; import { NodeTombstoneSynchronizer } from '@/synchronizers/node-tombstones';
import { NodeInteractionSynchronizer } from '@/synchronizers/node-interactions'; import { NodeInteractionSynchronizer } from '@/synchronizers/node-interactions';
import { FileSynchronizer } from '@/synchronizers/files';
import { DocumentUpdateSynchronizer } from '@/synchronizers/document-updates'; import { DocumentUpdateSynchronizer } from '@/synchronizers/document-updates';
type SocketUser = { type SocketUser = {
@@ -147,12 +146,6 @@ export class SocketConnection {
message.input, message.input,
cursor cursor
); );
} else if (message.input.type === 'files') {
if (!user.rootIds.has(message.input.rootId)) {
return null;
}
return new FileSynchronizer(message.id, user.user, message.input, cursor);
} else if (message.input.type === 'nodes') { } else if (message.input.type === 'nodes') {
if (!user.rootIds.has(message.input.rootId)) { if (!user.rootIds.has(message.input.rootId)) {
return null; return null;

View File

@@ -1,104 +0,0 @@
import {
SynchronizerOutputMessage,
SyncFilesInput,
SyncFileData,
} from '@colanode/core';
import { BaseSynchronizer } from '@/synchronizers/base';
import { Event } from '@/types/events';
import { database } from '@/data/database';
import { SelectFile } from '@/data/schema';
export class FileSynchronizer extends BaseSynchronizer<SyncFilesInput> {
public async fetchData(): Promise<SynchronizerOutputMessage<SyncFilesInput> | null> {
const files = await this.fetchFiles();
if (files.length === 0) {
return null;
}
return this.buildMessage(files);
}
public async fetchDataFromEvent(
event: Event
): Promise<SynchronizerOutputMessage<SyncFilesInput> | null> {
if (!this.shouldFetch(event)) {
return null;
}
const files = await this.fetchFiles();
if (files.length === 0) {
return null;
}
return this.buildMessage(files);
}
private async fetchFiles() {
if (this.status === 'fetching') {
return [];
}
this.status = 'fetching';
const files = await database
.selectFrom('files')
.selectAll()
.where('root_id', '=', this.input.rootId)
.where('revision', '>', this.cursor)
.orderBy('revision', 'asc')
.limit(20)
.execute();
this.status = 'pending';
return files;
}
private buildMessage(
unsyncedFiles: SelectFile[]
): SynchronizerOutputMessage<SyncFilesInput> {
const items: SyncFileData[] = unsyncedFiles.map((file) => ({
id: file.id,
type: file.type,
parentId: file.parent_id,
rootId: file.root_id,
workspaceId: file.workspace_id,
name: file.name,
originalName: file.original_name,
mimeType: file.mime_type,
size: file.size,
extension: file.extension,
createdAt: file.created_at.toISOString(),
createdBy: file.created_by,
updatedAt: file.updated_at?.toISOString() ?? null,
updatedBy: file.updated_by ?? null,
revision: file.revision.toString(),
status: file.status,
}));
return {
type: 'synchronizer_output',
userId: this.user.userId,
id: this.id,
items: items.map((item) => ({
cursor: item.revision,
data: item,
})),
};
}
private shouldFetch(event: Event) {
if (event.type === 'file_created' && event.rootId === this.input.rootId) {
return true;
}
if (event.type === 'file_updated' && event.rootId === this.input.rootId) {
return true;
}
if (event.type === 'file_deleted' && event.rootId === this.input.rootId) {
return true;
}
return false;
}
}

View File

@@ -1,6 +1,6 @@
import { FileType } from '../types/files'; import { FileSubtype } from '../types/files';
export const extractFileType = (mimeType: string): FileType => { export const extractFileSubtype = (mimeType: string): FileSubtype => {
if (mimeType.startsWith('image/')) { if (mimeType.startsWith('image/')) {
return 'image'; return 'image';
} }

View File

@@ -7,9 +7,16 @@ import { hasNodeRole } from '../../lib/permissions';
export const fileAttributesSchema = z.object({ export const fileAttributesSchema = z.object({
type: z.literal('file'), type: z.literal('file'),
name: z.string().optional(), subtype: z.string(),
parentId: z.string(), parentId: z.string(),
index: z.string().optional(), index: z.string().optional(),
name: z.string(),
originalName: z.string(),
mimeType: z.string(),
extension: z.string(),
size: z.number(),
version: z.string(),
status: z.number(),
}); });
export type FileAttributes = z.infer<typeof fileAttributesSchema>; export type FileAttributes = z.infer<typeof fileAttributesSchema>;

View File

@@ -1,34 +0,0 @@
import { FileStatus, FileType } from '../types/files';
export type SyncFilesInput = {
type: 'files';
rootId: string;
};
export type SyncFileData = {
id: string;
type: FileType;
parentId: string;
rootId: string;
workspaceId: string;
revision: string;
name: string;
originalName: string;
mimeType: string;
size: number;
extension: string;
status: FileStatus;
createdAt: string;
createdBy: string;
updatedAt: string | null;
updatedBy: string | null;
};
declare module '@colanode/core' {
interface SynchronizerMap {
files: {
input: SyncFilesInput;
data: SyncFileData;
};
}
}

View File

@@ -1,6 +1,5 @@
export * from './nodes'; export * from './nodes';
export * from './users'; export * from './users';
export * from './files';
export * from './node-reactions'; export * from './node-reactions';
export * from './node-interactions'; export * from './node-interactions';
export * from './node-tombstones'; export * from './node-tombstones';

View File

@@ -14,7 +14,7 @@ export type CompleteUploadOutput = {
success: boolean; success: boolean;
}; };
export type FileType = 'image' | 'video' | 'audio' | 'pdf' | 'other'; export type FileSubtype = 'image' | 'video' | 'audio' | 'pdf' | 'other';
export enum FileStatus { export enum FileStatus {
Pending = 0, Pending = 0,

View File

@@ -1,4 +1,4 @@
import { FileType } from './files'; import { FileSubtype } from './files';
export type SyncMutationsInput = { export type SyncMutationsInput = {
mutations: Mutation[]; mutations: Mutation[];
@@ -107,7 +107,7 @@ export type MarkNodeOpenedMutation = MutationBase & {
export type CreateFileMutationData = { export type CreateFileMutationData = {
id: string; id: string;
type: FileType; type: FileSubtype;
parentId: string; parentId: string;
rootId: string; rootId: string;
name: string; name: string;