From 183f0a5400d74276c86f4153779e83ace0297a7b Mon Sep 17 00:00:00 2001
From: Andrey Antukh <niwi@niwi.nz>
Date: Mon, 9 Dec 2019 16:27:01 +0100
Subject: [PATCH] :recycle: Refactor services (for add the project-file
 concept.

And fix many tests.
---
 backend/resources/migrations/0002.users.sql   |  23 +-
 .../resources/migrations/0003.projects.sql    | 125 ++++++++++-
 .../{0005.emails.sql => 0004.emails.sql}      |   0
 backend/resources/migrations/0004.pages.sql   |  65 ------
 .../{0006.images.sql => 0005.images.sql}      |  23 +-
 .../{0007.icons.sql => 0006.icons.sql}        |  26 +--
 backend/src/uxbox/config.clj                  |   2 +-
 backend/src/uxbox/db.clj                      |  20 +-
 backend/src/uxbox/fixtures.clj                | 157 ++++++++-----
 backend/src/uxbox/http/errors.clj             |  20 +-
 backend/src/uxbox/migrations.clj              |  15 +-
 backend/src/uxbox/services/init.clj           |  16 +-
 backend/src/uxbox/services/mutations.clj      |   4 +-
 .../src/uxbox/services/mutations/pages.clj    | 174 --------------
 .../services/mutations/project_files.clj      | 142 ++++++++++++
 .../services/mutations/project_pages.clj      | 190 ++++++++++++++++
 .../src/uxbox/services/mutations/projects.clj |  67 ++++--
 .../{user_storage.clj => user_attrs.clj}      |  16 +-
 .../mutations/{profiles.clj => users.clj}     |  25 +--
 backend/src/uxbox/services/queries.clj        |   1 +
 backend/src/uxbox/services/queries/pages.clj  |  92 --------
 .../uxbox/services/queries/project_files.clj  |  55 +++++
 .../uxbox/services/queries/project_pages.clj  | 145 ++++++++++++
 .../src/uxbox/services/queries/projects.clj   |  44 ++--
 .../{user_storage.clj => user_attrs.clj}      |   8 +-
 .../queries/{profiles.clj => users.clj}       |   6 +-
 backend/src/uxbox/util/dispatcher.clj         |  17 +-
 backend/src/uxbox/util/exceptions.clj         |   2 +-
 backend/src/uxbox/util/http.clj               | 212 +-----------------
 backend/src/uxbox/util/sql.clj                |   6 +-
 backend/test/uxbox/tests/helpers.clj          |  70 +++---
 backend/test/uxbox/tests/test_emails.clj      |  28 +--
 .../test/uxbox/tests/test_services_auth.clj   |  11 +-
 .../test/uxbox/tests/test_services_pages.clj  | 144 ------------
 .../tests/test_services_project_files.clj     |  76 +++++++
 .../tests/test_services_project_pages.clj     |  84 +++++++
 .../uxbox/tests/test_services_projects.clj    |  40 ++--
 ...orage.clj => test_services_user_attrs.clj} |  33 ++-
 ...test_users.clj => test_services_users.clj} |  76 +++----
 backend/test/uxbox/tests/test_util_svg.clj    |  25 ++-
 40 files changed, 1279 insertions(+), 1006 deletions(-)
 rename backend/resources/migrations/{0005.emails.sql => 0004.emails.sql} (100%)
 delete mode 100644 backend/resources/migrations/0004.pages.sql
 rename backend/resources/migrations/{0006.images.sql => 0005.images.sql} (64%)
 rename backend/resources/migrations/{0007.icons.sql => 0006.icons.sql} (59%)
 delete mode 100644 backend/src/uxbox/services/mutations/pages.clj
 create mode 100644 backend/src/uxbox/services/mutations/project_files.clj
 create mode 100644 backend/src/uxbox/services/mutations/project_pages.clj
 rename backend/src/uxbox/services/mutations/{user_storage.clj => user_attrs.clj} (75%)
 rename backend/src/uxbox/services/mutations/{profiles.clj => users.clj} (94%)
 delete mode 100644 backend/src/uxbox/services/queries/pages.clj
 create mode 100644 backend/src/uxbox/services/queries/project_files.clj
 create mode 100644 backend/src/uxbox/services/queries/project_pages.clj
 rename backend/src/uxbox/services/queries/{user_storage.clj => user_attrs.clj} (85%)
 rename backend/src/uxbox/services/queries/{profiles.clj => users.clj} (94%)
 delete mode 100644 backend/test/uxbox/tests/test_services_pages.clj
 create mode 100644 backend/test/uxbox/tests/test_services_project_files.clj
 create mode 100644 backend/test/uxbox/tests/test_services_project_pages.clj
 rename backend/test/uxbox/tests/{test_services_user_storage.clj => test_services_user_attrs.clj} (62%)
 rename backend/test/uxbox/tests/{test_users.clj => test_services_users.clj} (65%)

diff --git a/backend/resources/migrations/0002.users.sql b/backend/resources/migrations/0002.users.sql
index 8833f7821..cbf4c7d11 100644
--- a/backend/resources/migrations/0002.users.sql
+++ b/backend/resources/migrations/0002.users.sql
@@ -10,10 +10,11 @@ CREATE TABLE users (
   email text NOT NULL,
   photo text NOT NULL,
   password text NOT NULL,
-  metadata bytea NOT NULL
+
+  metadata bytea NULL DEFAULT NULL
 );
 
-CREATE TABLE IF NOT EXISTS user_storage (
+CREATE TABLE IF NOT EXISTS user_attrs (
   user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
 
   created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
@@ -25,7 +26,7 @@ CREATE TABLE IF NOT EXISTS user_storage (
   PRIMARY KEY (key, user_id)
 );
 
-CREATE TABLE user_tokens (
+CREATE TABLE IF NOT EXISTS tokens (
   user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
   token text NOT NULL,
 
@@ -35,7 +36,7 @@ CREATE TABLE user_tokens (
   PRIMARY KEY (token, user_id)
 );
 
-CREATE TABLE sessions (
+CREATE TABLE IF NOT EXISTS sessions (
   id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
 
   created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
@@ -56,17 +57,19 @@ VALUES ('00000000-0000-0000-0000-000000000000'::uuid,
         '!',
         '{}');
 
-CREATE UNIQUE INDEX users_username_idx
-    ON users USING btree (username)
+CREATE UNIQUE INDEX users__username__idx
+    ON users (username)
  WHERE deleted_at is null;
 
-CREATE UNIQUE INDEX users_email_idx
-    ON users USING btree (email)
+CREATE UNIQUE INDEX users__email__idx
+    ON users (email)
  WHERE deleted_at is null;
 
-CREATE TRIGGER users_modified_at_tgr BEFORE UPDATE ON users
+CREATE TRIGGER users__modified_at__tgr
+BEFORE UPDATE ON users
    FOR EACH ROW EXECUTE PROCEDURE update_modified_at();
 
-CREATE TRIGGER user_storage_modified_at_tgr BEFORE UPDATE ON user_storage
+CREATE TRIGGER user_attrs__modified_at__tgr
+BEFORE UPDATE ON user_attrs
    FOR EACH ROW EXECUTE PROCEDURE update_modified_at();
 
diff --git a/backend/resources/migrations/0003.projects.sql b/backend/resources/migrations/0003.projects.sql
index 561d3c130..42808f872 100644
--- a/backend/resources/migrations/0003.projects.sql
+++ b/backend/resources/migrations/0003.projects.sql
@@ -8,43 +8,150 @@ CREATE TABLE IF NOT EXISTS projects (
   modified_at timestamptz NOT NULL DEFAULT clock_timestamp(),
   deleted_at timestamptz DEFAULT NULL,
 
-  name text NOT NULL
+  name text NOT NULL,
+  metadata bytea NULL DEFAULT NULL
 );
 
-CREATE TABLE IF NOT EXISTS projects_users (
+CREATE TABLE IF NOT EXISTS project_users (
   user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
   project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
 
   created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
   modified_at timestamptz NOT NULL DEFAULT clock_timestamp(),
 
-  role text NOT NULL,
+  can_edit boolean DEFAULT false,
 
   PRIMARY KEY (user_id, project_id)
 );
 
+CREATE TABLE IF NOT EXISTS project_files (
+  id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+  user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+  project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
+
+  name text NOT NULL,
+
+  created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+  modified_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+  deleted_at timestamptz DEFAULT NULL,
+
+  metadata bytea NULL DEFAULT NULL
+);
+
+CREATE TABLE IF NOT EXISTS project_file_users (
+  file_id uuid NOT NULL REFERENCES project_files(id) ON DELETE CASCADE,
+  user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+
+  created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+  modified_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+
+  can_edit boolean DEFAULT false,
+
+  PRIMARY KEY (user_id, file_id)
+);
+
+CREATE TABLE IF NOT EXISTS project_pages (
+  id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+
+  user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+  file_id uuid NOT NULL REFERENCES project_files(id) ON DELETE CASCADE,
+
+  created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+  modified_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+  deleted_at timestamptz DEFAULT NULL,
+
+  version bigint NOT NULL,
+  ordering smallint NOT NULL,
+
+  name text NOT NULL,
+  data bytea NOT NULL,
+  metadata bytea NULL DEFAULT NULL
+);
+
+CREATE TABLE IF NOT EXISTS project_page_history (
+  id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+
+  user_id uuid NULL REFERENCES users(id) ON DELETE SET NULL,
+  page_id uuid NOT NULL REFERENCES project_pages(id) ON DELETE CASCADE,
+
+  created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+  modified_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+  version bigint NOT NULL DEFAULT 0,
+
+  pinned bool NOT NULL DEFAULT false,
+  label text NOT NULL DEFAULT '',
+
+  data bytea NOT NULL
+);
+
 -- Indexes
 
-CREATE INDEX projects_user_idx ON projects(user_id);
-CREATE INDEX projects_users_user_id_idx ON projects_users(project_id);
-CREATE INDEX projects_users_project_id_idx ON projects_users(user_id);
+CREATE INDEX projects__user_id__idx ON projects(user_id);
+
+CREATE INDEX project_files__user_id__idx ON project_files(user_id);
+CREATE INDEX project_files__project_id__idx ON project_files(project_id);
+
+CREATE INDEX project_pages__user_id__idx ON project_pages(user_id);
+CREATE INDEX project_pages__file_id__idx ON project_pages(file_id);
+
+CREATE INDEX project_page_history__page_id__idx ON project_page_history(page_id);
+CREATE INDEX project_page_history__user_id__idx ON project_page_history(user_id);
 
 -- Triggers
 
 CREATE OR REPLACE FUNCTION handle_project_insert()
   RETURNS TRIGGER AS $$
   BEGIN
-    INSERT INTO projects_users (user_id, project_id, role)
-    VALUES (NEW.user_id, NEW.id, 'owner');
+    INSERT INTO project_users (user_id, project_id, can_edit)
+    VALUES (NEW.user_id, NEW.id, true);
 
     RETURN NEW;
   END;
 $$ LANGUAGE plpgsql;
 
+CREATE OR REPLACE FUNCTION handle_page_update()
+  RETURNS TRIGGER AS $pagechange$
+  DECLARE
+    current_dt timestamptz := clock_timestamp();
+    proj_id uuid;
+  BEGIN
+    UPDATE project_files
+       SET modified_at = current_dt
+     WHERE id = OLD.file_id
+    RETURNING project_id
+      INTO STRICT proj_id;
+
+    --- Update projects modified_at attribute when a
+    --- page of that project is modified.
+    UPDATE projects
+       SET modified_at = current_dt
+     WHERE id = proj_id;
+
+    RETURN NEW;
+  END;
+$pagechange$ LANGUAGE plpgsql;
+
 CREATE TRIGGER projects_on_insert_tgr
  AFTER INSERT ON projects
    FOR EACH ROW EXECUTE PROCEDURE handle_project_insert();
 
-CREATE TRIGGER projects_modified_at_tgr
+CREATE TRIGGER pages__on_update__tgr
+BEFORE UPDATE ON project_pages
+   FOR EACH ROW EXECUTE PROCEDURE handle_page_update();
+
+
+CREATE TRIGGER projects__modified_at__tgr
 BEFORE UPDATE ON projects
    FOR EACH ROW EXECUTE PROCEDURE update_modified_at();
+
+CREATE TRIGGER project_files__modified_at__tgr
+BEFORE UPDATE ON project_files
+   FOR EACH ROW EXECUTE PROCEDURE update_modified_at();
+
+CREATE TRIGGER project_pages__modified_at__tgr
+BEFORE UPDATE ON project_pages
+   FOR EACH ROW EXECUTE PROCEDURE update_modified_at();
+
+CREATE TRIGGER project_page_history__modified_at__tgr
+BEFORE UPDATE ON project_page_history
+   FOR EACH ROW EXECUTE PROCEDURE update_modified_at();
diff --git a/backend/resources/migrations/0005.emails.sql b/backend/resources/migrations/0004.emails.sql
similarity index 100%
rename from backend/resources/migrations/0005.emails.sql
rename to backend/resources/migrations/0004.emails.sql
diff --git a/backend/resources/migrations/0004.pages.sql b/backend/resources/migrations/0004.pages.sql
deleted file mode 100644
index a867a700c..000000000
--- a/backend/resources/migrations/0004.pages.sql
+++ /dev/null
@@ -1,65 +0,0 @@
--- Tables
-
-CREATE TABLE IF NOT EXISTS pages (
-  id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
-
-  user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-  project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
-
-  created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
-  modified_at timestamptz NOT NULL DEFAULT clock_timestamp(),
-  deleted_at timestamptz DEFAULT NULL,
-
-  version bigint NOT NULL,
-  ordering smallint NOT NULL,
-
-  name text NOT NULL,
-  data bytea NOT NULL,
-  metadata bytea NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS pages_history (
-  id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
-
-  user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-  page_id uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
-
-  created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
-  modified_at timestamptz NOT NULL DEFAULT clock_timestamp(),
-  version bigint NOT NULL DEFAULT 0,
-
-  pinned bool NOT NULL DEFAULT false,
-  label text NOT NULL DEFAULT '',
-  data bytea NOT NULL,
-  metadata bytea NOT NULL
-);
-
--- Indexes
-
-CREATE INDEX pages_project_idx ON pages(project_id);
-CREATE INDEX pages_user_idx ON pages(user_id);
-CREATE INDEX pages_history_page_idx ON pages_history(page_id);
-CREATE INDEX pages_history_user_idx ON pages_history(user_id);
-
--- Triggers
-
-CREATE OR REPLACE FUNCTION handle_page_update()
-  RETURNS TRIGGER AS $pagechange$
-  BEGIN
-    --- Update projects modified_at attribute when a
-    --- page of that project is modified.
-    UPDATE projects SET modified_at = clock_timestamp()
-     WHERE id = OLD.project_id;
-
-    RETURN NEW;
-  END;
-$pagechange$ LANGUAGE plpgsql;
-
-CREATE TRIGGER page_on_update_tgr BEFORE UPDATE ON pages
-   FOR EACH ROW EXECUTE PROCEDURE handle_page_update();
-
-CREATE TRIGGER pages_modified_at_tgr BEFORE UPDATE ON pages
-   FOR EACH ROW EXECUTE PROCEDURE update_modified_at();
-
-CREATE TRIGGER pages_history_modified_at_tgr BEFORE UPDATE ON pages
-   FOR EACH ROW EXECUTE PROCEDURE update_modified_at();
diff --git a/backend/resources/migrations/0006.images.sql b/backend/resources/migrations/0005.images.sql
similarity index 64%
rename from backend/resources/migrations/0006.images.sql
rename to backend/resources/migrations/0005.images.sql
index ca6dea90f..b97c91004 100644
--- a/backend/resources/migrations/0006.images.sql
+++ b/backend/resources/migrations/0005.images.sql
@@ -1,6 +1,6 @@
 -- Tables
 
-CREATE TABLE IF NOT EXISTS images_collections (
+CREATE TABLE IF NOT EXISTS image_collections (
   id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
   user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
 
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS images (
   width int NOT NULL,
   height int NOT NULL,
   mimetype text NOT NULL,
-  collection_id uuid REFERENCES images_collections(id)
+  collection_id uuid REFERENCES image_collections(id)
                      ON DELETE SET NULL
                      DEFAULT NULL,
   name text NOT NULL,
@@ -31,20 +31,17 @@ CREATE TABLE IF NOT EXISTS images (
 
 -- Indexes
 
-CREATE INDEX images_collections_user_idx
-    ON images_collections (user_id);
-
-CREATE INDEX images_collection_idx
-    ON images (collection_id);
-
-CREATE INDEX images_user_idx
-    ON images (user_id);
+CREATE INDEX image_collections__user_id__idx ON image_collections (user_id);
+CREATE INDEX images__collection_id__idx ON images (collection_id);
+CREATE INDEX images__user_id__idx ON images (user_id);
 
 -- Triggers
 
-CREATE TRIGGER images_collections_modified_at_tgr BEFORE UPDATE ON images_collections
+CREATE TRIGGER image_collections__modified_at__tgr
+BEFORE UPDATE ON image_collections
    FOR EACH ROW EXECUTE PROCEDURE update_modified_at();
 
-CREATE TRIGGER images_modified_at_tgr BEFORE UPDATE ON images
-  FOR EACH ROW EXECUTE PROCEDURE update_modified_at();
+CREATE TRIGGER images__modified_at__tgr
+BEFORE UPDATE ON images
+   FOR EACH ROW EXECUTE PROCEDURE update_modified_at();
 
diff --git a/backend/resources/migrations/0007.icons.sql b/backend/resources/migrations/0006.icons.sql
similarity index 59%
rename from backend/resources/migrations/0007.icons.sql
rename to backend/resources/migrations/0006.icons.sql
index 12bbbf5e0..476ce70b8 100644
--- a/backend/resources/migrations/0007.icons.sql
+++ b/backend/resources/migrations/0006.icons.sql
@@ -1,6 +1,6 @@
 -- Tables
 
-CREATE TABLE IF NOT EXISTS icons_collections (
+CREATE TABLE IF NOT EXISTS icon_collections (
   id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
   user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
 
@@ -23,27 +23,23 @@ CREATE TABLE IF NOT EXISTS icons (
   content text NOT NULL,
   metadata bytea NOT NULL,
 
-  collection_id uuid REFERENCES icons_collections(id)
+  collection_id uuid REFERENCES icon_collections(id)
                      ON DELETE SET NULL
                      DEFAULT NULL
 );
 
 -- Indexes
 
-CREATE INDEX icon_colections_user_idx
-    ON icons_collections (user_id);
-
-CREATE INDEX icons_user_idx
-    ON icons (user_id);
-
-CREATE INDEX icons_collection_idx
-    ON icons (collection_id);
+CREATE INDEX icon_colections__user_id__idx ON icon_collections (user_id);
+CREATE INDEX icons__user_id__idx ON icons(user_id);
+CREATE INDEX icons__collection_id__idx ON icons(collection_id);
 
 -- Triggers
 
-CREATE TRIGGER icons_collections_modified_at_tgr BEFORE UPDATE ON icons_collections
-  FOR EACH ROW EXECUTE PROCEDURE update_modified_at();
-
-CREATE TRIGGER icons_modified_at_tgr BEFORE UPDATE ON icons
-  FOR EACH ROW EXECUTE PROCEDURE update_modified_at();
+CREATE TRIGGER icon_collections__modified_at__tgr
+BEFORE UPDATE ON icon_collections
+   FOR EACH ROW EXECUTE PROCEDURE update_modified_at();
 
+CREATE TRIGGER icons__modified_at__tgr
+BEFORE UPDATE ON icons
+   FOR EACH ROW EXECUTE PROCEDURE update_modified_at();
diff --git a/backend/src/uxbox/config.clj b/backend/src/uxbox/config.clj
index 20f8eb01d..1133e319f 100644
--- a/backend/src/uxbox/config.clj
+++ b/backend/src/uxbox/config.clj
@@ -50,7 +50,7 @@
    :email-reply-to (lookup-env env :uxbox-email-reply-to "no-reply@uxbox.io")
    :email-from (lookup-env env :uxbox-email-from "no-reply@uxbox.io")
 
-   :smtp-host (lookup-env env :uxbox-smtp-host "smtp")
+   :smtp-host (lookup-env env :uxbox-smtp-host "localhost")
    :smtp-port (lookup-env env :uxbox-smtp-port 25)
    :smtp-user (lookup-env env :uxbox-smtp-user nil)
    :smtp-password (lookup-env env :uxbox-smtp-password nil)
diff --git a/backend/src/uxbox/db.clj b/backend/src/uxbox/db.clj
index a421437e1..84d92e026 100644
--- a/backend/src/uxbox/db.clj
+++ b/backend/src/uxbox/db.clj
@@ -13,6 +13,7 @@
    [uxbox.config :as cfg]
    [uxbox.core :refer [system]]
    [uxbox.util.data :as data]
+   [uxbox.util.exceptions :as ex]
    [uxbox.util.pgsql :as pg]
    [vertx.core :as vx])
   (:import io.vertx.core.buffer.Buffer))
@@ -33,17 +34,22 @@
   :start (create-pool cfg/config system))
 
 (defmacro with-atomic
-  [& args]
-  `(pg/with-atomic ~@args))
+  [bindings & args]
+  `(pg/with-atomic ~bindings (p/do! ~@args)))
 
 (def row-xfm
   (comp (map pg/row->map)
         (map data/normalize-attrs)))
 
 (defmacro query
-  [& args]
-  `(pg/query ~@args {:xfm row-xfm}))
-
+  [conn sql]
+  `(-> (pg/query ~conn ~sql {:xfm row-xfm})
+       (p/catch' (fn [err#]
+                   (ex/raise :type :database-error
+                             :cause err#)))))
 (defmacro query-one
-  [& args]
-  `(pg/query-one ~@args {:xfm row-xfm}))
+  [conn sql]
+  `(-> (pg/query-one ~conn ~sql {:xfm row-xfm})
+       (p/catch' (fn [err#]
+                   (ex/raise :type :database-error
+                             :cause err#)))))
diff --git a/backend/src/uxbox/fixtures.clj b/backend/src/uxbox/fixtures.clj
index 857dc2712..1d7871059 100644
--- a/backend/src/uxbox/fixtures.clj
+++ b/backend/src/uxbox/fixtures.clj
@@ -7,6 +7,7 @@
 (ns uxbox.fixtures
   "A initial fixtures."
   (:require
+   [clojure.tools.logging :as log]
    [buddy.hashers :as hashers]
    [mount.core :as mount]
    [promesa.core :as p]
@@ -20,26 +21,27 @@
 
 (defn- mk-uuid
   [prefix & args]
-  (uuid/namespaced uuid/oid (apply str prefix args)))
+  (uuid/namespaced uuid/oid (apply str prefix (interpose "-" args))))
 
 ;; --- Users creation
 
 (def create-user-sql
-  "insert into users (id, fullname, username, email, password, metadata, photo)
-   values ($1, $2, $3, $4, $5, $6, $7)
+  "insert into users (id, fullname, username, email, password, photo)
+   values ($1, $2, $3, $4, $5, $6)
    returning *;")
 
+(def password (hashers/encrypt "123123"))
+
 (defn create-user
-  [conn i]
-  (println "create user" i)
-  (db/query-one conn [create-user-sql
-                      (mk-uuid "user" i)
-                      (str "User " i)
-                      (str "user" i)
-                      (str "user" i ".test@uxbox.io")
-                      (hashers/encrypt "123123")
-                      (blob/encode {})
-                      ""]))
+  [conn user-index]
+  (log/info "create user" user-index)
+  (let [sql create-user-sql
+        id (mk-uuid "user" user-index)
+        fullname (str "User " user-index)
+        username (str "user" user-index)
+        email (str "user" user-index ".test@uxbox.io")
+        photo ""]
+  (db/query-one conn [sql id fullname username email password photo])))
 
 ;; --- Projects creation
 
@@ -49,29 +51,46 @@
    returning *;")
 
 (defn create-project
-  [conn [pjid uid]]
-  (println "create project" pjid "(for user=" uid ")")
-  (db/query-one conn [create-project-sql
-                      (mk-uuid "project" pjid uid)
-                      (mk-uuid "user" uid)
-                      (str "sample project " pjid)]))
+  [conn [project-index user-index]]
+  (log/info "create project" user-index project-index)
+  (let [sql create-project-sql
+        id (mk-uuid "project" project-index user-index)
+        user-id (mk-uuid "user" user-index)
+        name (str "sample project " project-index)]
+    (db/query-one conn [sql id user-id name])))
 
-;; --- Pages creation
+;; --- Create Page Files
+
+(def create-file-sql
+  "insert into project_files (id, user_id, project_id, name)
+   values ($1, $2, $3, $4) returning id")
+
+(defn create-file
+  [conn [file-index project-index user-index]]
+  (log/info "create page file" user-index project-index file-index)
+  (let [sql create-file-sql
+        id (mk-uuid "page-file" file-index project-index user-index)
+        user-id (mk-uuid "user" user-index)
+        project-id (mk-uuid "project" project-index user-index)
+        name (str "Sample file " file-index)]
+    (db/query-one conn [sql id user-id project-id name])))
+
+;; --- Create Pages
 
 (def create-page-sql
-  "insert into pages (id, user_id, project_id, name,
+  "insert into project_pages (id, user_id, file_id, name,
                       version, ordering, data, metadata)
    values ($1, $2, $3, $4, $5, $6, $7, $8)
    returning id;")
 
 (def create-page-history-sql
-  "insert into pages_history (page_id, user_id, version, data, metadata)
-   values ($1, $2, $3, $4, $5)
+  "insert into project_page_history (page_id, user_id, version, data)
+   values ($1, $2, $3, $4)
    returning id;")
 
 (defn create-page
-  [conn [pjid paid uid]]
-  (println "create page" paid "(for project=" pjid ", user=" uid ")")
+  [conn [page-index file-index project-index user-index]]
+  (log/info "create page" user-index project-index file-index page-index)
   (let [canvas {:id (mk-uuid "canvas" 1)
                 :name "Canvas-1"
                 :type :canvas
@@ -82,29 +101,61 @@
         data {:shapes []
               :canvas [(:id canvas)]
               :shapes-by-id {(:id canvas) canvas}}
+
+        sql1 create-page-sql
+        sql2 create-page-history-sql
+
+        id (mk-uuid "page" page-index file-index project-index user-index)
+        user-id (mk-uuid "user" user-index)
+        file-id (mk-uuid "page-file" file-index project-index user-index)
+        name (str "page " page-index)
+        version 0
+        ordering page-index
         data (blob/encode data)
         mdata (blob/encode {})]
     (p/do!
-     (db/query-one conn [create-page-sql
-                         (mk-uuid "page" pjid paid uid)
-                         (mk-uuid "user" uid)
-                         (mk-uuid "project" pjid uid)
-                         (str "page " paid)
-                         0
-                         paid
-                         data
-                         mdata])
-     (db/query-one conn [create-page-history-sql
-                         (mk-uuid "page" pjid paid uid)
-                         (mk-uuid "user" uid)
-                         0
-                         data
-                         mdata]))))
+     (db/query-one conn [sql1 id user-id file-id name version ordering data mdata])
+     #_(db/query-one conn [sql2 id user-id version data]))))
 
+(def preset-small
+  {:users 50
+   :projects 5
+   :files 5
+   :pages 3})
 
-(def num-users 5)
-(def num-projects 5)
-(def num-pages 5)
+(def preset-medium
+  {:users 500
+   :projects 20
+   :files 5
+   :pages 3})
+
+(def preset-big
+  {:users 5000
+   :projects 50
+   :files 5
+   :pages 4})
+
+(defn run
+  [opts]
+  (db/with-atomic [conn db/pool]
+    (p/do!
+     (p/run! #(create-user conn %) (range (:users opts)))
+     (p/run! #(create-project conn %)
+             (for [user-index    (range (:users opts))
+                   project-index (range (:projects opts))]
+               [project-index user-index]))
+     (p/run! #(create-file conn %)
+             (for [user-index    (range (:users opts))
+                   project-index (range (:projects opts))
+                   file-index  (range (:files opts))]
+               [file-index project-index user-index]))
+     (p/run! #(create-page conn %)
+             (for [user-index    (range (:users opts))
+                   project-index (range (:projects opts))
+                   file-index  (range (:files opts))
+                   page-index    (range (:pages opts))]
+               [page-index file-index project-index user-index]))
+     (p/promise nil))))
 
 (defn -main
   [& args]
@@ -115,18 +166,12 @@
                       #'uxbox.db/pool
                       #'uxbox.migrations/migrations})
         (mount/start))
-    @(db/with-atomic [conn db/pool]
-       (p/do!
-        (p/run! #(create-user conn %) (range num-users))
-        (p/run! #(create-project conn %)
-                (for [uid (range num-users)
-                      pjid  (range num-projects)]
-                  [pjid uid]))
-        (p/run! #(create-page conn %)
-                (for [pjid(range num-projects)
-                      paid  (range num-pages)
-                      uid (range num-users)]
-                  [pjid paid uid]))
-        (p/promise 1)))
+    (let [preset (case (first args)
+                   (nil "small") preset-small
+                   "medium" preset-medium
+                   "big" preset-big
+                   preset-small)]
+      (log/info "Using preset:" (pr-str preset))
+      (deref (run preset)))
     (finally
       (mount/stop))))
diff --git a/backend/src/uxbox/http/errors.clj b/backend/src/uxbox/http/errors.clj
index 614371667..0863e1ae1 100644
--- a/backend/src/uxbox/http/errors.clj
+++ b/backend/src/uxbox/http/errors.clj
@@ -8,6 +8,7 @@
   "A errors handling for the http server."
   (:require
    [clojure.tools.logging :as log]
+   [cuerdas.core :as str]
    [io.aviso.exception :as e]))
 
 (defmulti handle-exception
@@ -16,9 +17,18 @@
 
 (defmethod handle-exception :validation
   [err req]
-  (let [response (ex-data err)]
-    {:status 400
-     :body response}))
+  (let [header (get-in req [:headers "accept"])
+        response (ex-data err)]
+    (cond
+      (and (str/starts-with? header "text/html")
+           (= :spec-validation (:code response)))
+      {:status 400
+       :headers {"content-type" "text/html"}
+       :body (str "<pre style='font-size:16px'>" (:explain response) "</pre>\n")}
+
+      :else
+      {:status 400
+       :body response})))
 
 (defmethod handle-exception :not-found
   [err req]
@@ -26,6 +36,10 @@
     {:status 404
      :body response}))
 
+(defmethod handle-exception :service-error
+  [err req]
+  (handle-exception (.getCause err) req))
+
 (defmethod handle-exception :parse
   [err req]
   {:status 400
diff --git a/backend/src/uxbox/migrations.clj b/backend/src/uxbox/migrations.clj
index 098d42cf8..22de8de26 100644
--- a/backend/src/uxbox/migrations.clj
+++ b/backend/src/uxbox/migrations.clj
@@ -26,18 +26,15 @@
     {:desc "Initial projects tables"
      :name "0003-projects"
      :fn (mg/resource "migrations/0003.projects.sql")}
-    {:desc "Initial pages tables"
-     :name "0004-pages"
-     :fn (mg/resource "migrations/0004.pages.sql")}
     {:desc "Initial emails related tables"
-     :name "0005-emails"
-     :fn (mg/resource "migrations/0005.emails.sql")}
+     :name "0004-emails"
+     :fn (mg/resource "migrations/0004.emails.sql")}
     {:desc "Initial images tables"
-     :name "0006-images"
-     :fn (mg/resource "migrations/0006.images.sql")}
+     :name "0005-images"
+     :fn (mg/resource "migrations/0005.images.sql")}
     {:desc "Initial icons tables"
-     :name "0007-icons"
-     :fn (mg/resource "migrations/0007.icons.sql")}
+     :name "0006-icons"
+     :fn (mg/resource "migrations/0006.icons.sql")}
     ]})
 
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/backend/src/uxbox/services/init.clj b/backend/src/uxbox/services/init.clj
index e3a846d2d..bda9a1af8 100644
--- a/backend/src/uxbox/services/init.clj
+++ b/backend/src/uxbox/services/init.clj
@@ -13,20 +13,22 @@
   []
   (require 'uxbox.services.queries.icons)
   (require 'uxbox.services.queries.images)
-  (require 'uxbox.services.queries.pages)
-  (require 'uxbox.services.queries.profiles)
   (require 'uxbox.services.queries.projects)
-  (require 'uxbox.services.queries.user-storage))
+  (require 'uxbox.services.queries.project-files)
+  (require 'uxbox.services.queries.project-pages)
+  (require 'uxbox.services.queries.users)
+  (require 'uxbox.services.queries.user-attrs))
 
 (defn- load-mutation-services
   []
-  (require 'uxbox.services.mutations.auth)
   (require 'uxbox.services.mutations.icons)
   (require 'uxbox.services.mutations.images)
   (require 'uxbox.services.mutations.projects)
-  (require 'uxbox.services.mutations.pages)
-  (require 'uxbox.services.mutations.profiles)
-  (require 'uxbox.services.mutations.user-storage))
+  (require 'uxbox.services.mutations.project-files)
+  (require 'uxbox.services.mutations.project-pages)
+  (require 'uxbox.services.mutations.auth)
+  (require 'uxbox.services.mutations.users)
+  (require 'uxbox.services.mutations.user-attrs))
 
 (defstate query-services
   :start (load-query-services))
diff --git a/backend/src/uxbox/services/mutations.clj b/backend/src/uxbox/services/mutations.clj
index f62a08d89..6bfa98c50 100644
--- a/backend/src/uxbox/services/mutations.clj
+++ b/backend/src/uxbox/services/mutations.clj
@@ -6,11 +6,13 @@
 
 (ns uxbox.services.mutations
   (:require
-   [uxbox.util.dispatcher :as uds]))
+   [uxbox.util.dispatcher :as uds]
+   [uxbox.util.exceptions :as ex]))
 
 (uds/defservice handle
   {:dispatch-by ::type
    :interceptors [uds/spec-interceptor
+                  uds/wrap-errors
                   #_logging-interceptor
                   #_context-interceptor]})
 
diff --git a/backend/src/uxbox/services/mutations/pages.clj b/backend/src/uxbox/services/mutations/pages.clj
deleted file mode 100644
index a70fe7125..000000000
--- a/backend/src/uxbox/services/mutations/pages.clj
+++ /dev/null
@@ -1,174 +0,0 @@
-;; This Source Code Form is subject to the terms of the Mozilla Public
-;; License, v. 2.0. If a copy of the MPL was not distributed with this
-;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
-;;
-;; Copyright (c) 2019 Andrey Antukh <niwi@niwi.nz>
-
-(ns uxbox.services.mutations.pages
-  (:require
-   [clojure.spec.alpha :as s]
-   [promesa.core :as p]
-   [uxbox.db :as db]
-   [uxbox.util.spec :as us]
-   [uxbox.services.mutations :as sm]
-   [uxbox.services.util :as su]
-   [uxbox.services.queries.pages :refer [decode-row]]
-   [uxbox.util.sql :as sql]
-   [uxbox.util.blob :as blob]
-   [uxbox.util.uuid :as uuid]))
-
-;; --- Helpers & Specs
-
-;; TODO: validate `:data` and `:metadata`
-
-(s/def ::id ::us/uuid)
-(s/def ::name ::us/string)
-(s/def ::data any?)
-(s/def ::user ::us/uuid)
-(s/def ::project-id ::us/uuid)
-(s/def ::metadata any?)
-(s/def ::ordering ::us/number)
-
-;; --- Mutation: Create Page
-
-(declare create-page)
-
-(s/def ::create-page
-  (s/keys :req-un [::data ::user ::project-id ::name ::metadata]
-          :opt-un [::id]))
-
-(sm/defmutation ::create-page
-  [params]
-  (create-page db/pool params))
-
-(defn create-page
-  [conn {:keys [id user project-id name ordering data metadata] :as params}]
-  (let [sql "insert into pages (id, user_id, project_id, name,
-                                ordering, data, metadata, version)
-             values ($1, $2, $3, $4, $5, $6, $7, 0)
-             returning *"
-        id   (or id (uuid/next))
-        data (blob/encode data)
-        mdata (blob/encode metadata)]
-    (-> (db/query-one db/pool [sql id user project-id name ordering data mdata])
-        (p/then' decode-row))))
-
-;; --- Mutation: Update Page
-
-(s/def ::update-page
-  (s/keys :req-un [::data ::user ::project-id ::name ::data ::metadata ::id]))
-
-(letfn [(select-for-update [conn id]
-          (let [sql "select p.id, p.version
-                       from pages as p
-                      where p.id = $1
-                        and deleted_at is null
-                        for update;"]
-            (-> (db/query-one conn [sql id])
-                (p/then' su/raise-not-found-if-nil))))
-
-        (update-page [conn {:keys [id name version data metadata user]}]
-          (let [sql "update pages
-                        set name = $1,
-                            version = $2,
-                            data = $3,
-                            metadata = $4
-                      where id = $5
-                        and user_id = $6"]
-            (-> (db/query-one conn [sql name version data metadata id user])
-                (p/then' su/constantly-nil))))
-
-        (update-history [conn {:keys [user id version data metadata]}]
-          (let [sql "insert into pages_history (user_id, page_id, version, data, metadata)
-                     values ($1, $2, $3, $4, $5)"]
-            (-> (db/query-one conn [sql user id version data metadata])
-                (p/then' su/constantly-nil))))]
-
-  (sm/defmutation ::update-page
-    [{:keys [id data metadata] :as params}]
-    (db/with-atomic [conn db/pool]
-      (-> (select-for-update conn id)
-          (p/then (fn [{:keys [id version]}]
-                    (let [data (blob/encode data)
-                          mdata (blob/encode metadata)
-                          version (inc version)
-                          params (assoc params
-                                        :id id
-                                        :version version
-                                        :data data
-                                        :metadata mdata)]
-                      (p/do! (update-page conn params)
-                             (update-history conn params)
-                             (select-keys params [:id :version])))))))))
-
-;; --- Mutation: Rename Page
-
-(s/def ::rename-page
-  (s/keys :req-un [::id ::name ::user]))
-
-(sm/defmutation ::rename-page
-  [{:keys [id name user]}]
-  (let [sql "update pages
-                set name = $3
-              where id = $1
-                and user_id = $2
-                and deleted_at is null"]
-    (-> (db/query-one db/pool [sql id user name])
-        (p/then su/constantly-nil))))
-
-;; --- Mutation: Update Page Metadata
-
-(s/def ::update-page-metadata
-  (s/keys :req-un [::user ::project-id ::name ::metadata ::id]))
-
-(sm/defmutation ::update-page-metadata
-  [{:keys [id user project-id name metadata]}]
-  (let [sql "update pages
-                set name = $3,
-                    metadata = $4
-              where id = $1
-                and user_id = $2
-                and deleted_at is null
-             returning *"
-        mdata (blob/encode metadata)]
-    (-> (db/query-one db/pool [sql id user name mdata])
-        (p/then' decode-row))))
-
-;; --- Mutation: Delete Page
-
-(s/def ::delete-page
-  (s/keys :req-un [::user ::id]))
-
-(sm/defmutation ::delete-page
-  [{:keys [id user]}]
-  (let [sql "update pages
-                set deleted_at = clock_timestamp()
-              where id = $1
-                and user_id = $2
-                and deleted_at is null
-             returning id"]
-    (-> (db/query-one db/pool [sql id user])
-        (p/then su/raise-not-found-if-nil)
-        (p/then su/constantly-nil))))
-
-;; ;; --- Update Page History
-
-;; (defn update-page-history
-;;   [conn {:keys [user id label pinned]}]
-;;   (let [sqlv (sql/update-page-history {:user user
-;;                                        :id id
-;;                                        :label label
-;;                                        :pinned pinned})]
-;;     (some-> (db/fetch-one conn sqlv)
-;;             (decode-row))))
-
-;; (s/def ::label ::us/string)
-;; (s/def ::update-page-history
-;;   (s/keys :req-un [::user ::id ::pinned ::label]))
-
-;; (sm/defmutation :update-page-history
-;;   {:doc "Update page history"
-;;    :spec ::update-page-history}
-;;   [params]
-;;   (with-open [conn (db/connection)]
-;;     (update-page-history conn params)))
diff --git a/backend/src/uxbox/services/mutations/project_files.clj b/backend/src/uxbox/services/mutations/project_files.clj
new file mode 100644
index 000000000..521aaf97b
--- /dev/null
+++ b/backend/src/uxbox/services/mutations/project_files.clj
@@ -0,0 +1,142 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) 2019 Andrey Antukh <niwi@niwi.nz>
+
+(ns uxbox.services.mutations.project-files
+  (:require
+   [clojure.spec.alpha :as s]
+   [promesa.core :as p]
+   [uxbox.db :as db]
+   [uxbox.util.spec :as us]
+   [uxbox.services.mutations :as sm]
+   [uxbox.services.mutations.projects :as proj]
+   [uxbox.services.util :as su]
+   [uxbox.util.exceptions :as ex]
+   [uxbox.util.blob :as blob]
+   [uxbox.util.uuid :as uuid]))
+
+;; --- Helpers & Specs
+
+(s/def ::id ::us/uuid)
+(s/def ::name ::us/string)
+(s/def ::user ::us/uuid)
+(s/def ::project-id ::us/uuid)
+
+;; --- Permissions Checks
+
+;; A query that returns all (not-equal) user assignations for a
+;; requested file (project level and file level).
+
+;; Is important having the condition of user_id in the join and not in
+;; where clause because we need all results independently if value is
+;; true, false or null; with that, the empty result means there are no
+;; file found.
+
+(def ^:private sql:file-permissions
+  "select pf.id,
+          pfu.can_edit as can_edit
+     from project_files as pf
+     left join project_file_users as pfu
+       on (pfu.file_id = pf.id and pfu.user_id = $1)
+    where pf.id = $2
+   union all
+   select pf.id,
+          pu.can_edit as can_edit
+     from project_files as pf
+     left join project_users as pu
+       on (pf.project_id = pu.project_id and pu.user_id = $1)
+    where pf.id = $2")
+
+(defn check-edition-permissions!
+  [conn user file-id]
+  (-> (db/query conn [sql:file-permissions user file-id])
+      (p/then' seq)
+      (p/then' su/raise-not-found-if-nil)
+      (p/then' (fn [rows]
+                 (when-not (some :can-edit rows)
+                   (ex/raise :type :validation
+                             :code :not-authorized))))))
+
+;; --- Mutation: Create Project
+
+(declare create-file)
+(declare create-page)
+
+(s/def ::create-project-file
+  (s/keys :req-un [::user ::name ::project-id]
+          :opt-un [::id]))
+
+(sm/defmutation ::create-project-file
+  [{:keys [user project-id] :as params}]
+  (db/with-atomic [conn db/pool]
+    (proj/check-edition-permissions! conn user project-id)
+    (p/let [file (create-file conn params)]
+      (create-page conn (assoc params :file-id (:id file)))
+      file)))
+
+(defn create-file
+  [conn {:keys [id user name project-id] :as params}]
+  (let [id (or id (uuid/next))
+        sql "insert into project_files (id, user_id, project_id, name)
+             values ($1, $2, $3, $4) returning *"]
+    (db/query-one conn [sql id user project-id name])))
+
+(defn- create-page
+  "Creates an initial page for the file."
+  [conn {:keys [user file-id] :as params}]
+  (let [id  (uuid/next)
+        name "Page 1"
+        data (blob/encode {})
+        sql "insert into project_pages (id, user_id, file_id, name, version,
+                                        ordering, data)
+             values ($1, $2, $3, $4, 0, 1, $5) returning id"]
+    (db/query-one conn [sql id user file-id name data])))
+
+;; --- Mutation: Update Project
+
+(declare update-file)
+
+(s/def ::update-project-file
+  (s/keys :req-un [::user ::name ::id]))
+
+(sm/defmutation ::update-project-file
+  [{:keys [id user] :as params}]
+  (db/with-atomic [conn db/pool]
+    (check-edition-permissions! conn user id)
+    (update-file conn params)))
+
+(defn- update-file
+  [conn {:keys [id name user] :as params}]
+  (let [sql "update project_files
+                set name = $2
+              where id = $1
+                and deleted_at is null"]
+    (-> (db/query-one conn [sql id name])
+        (p/then' su/constantly-nil))))
+
+;; --- Mutation: Delete Project
+
+(declare delete-file)
+
+(s/def ::delete-project-file
+  (s/keys :req-un [::id ::user]))
+
+(sm/defmutation ::delete-project-file
+  [{:keys [id user] :as params}]
+  (db/with-atomic [conn db/pool]
+    (check-edition-permissions! conn user id)
+    (delete-file conn params)))
+
+(def ^:private sql:delete-file
+  "update project_files
+      set deleted_at = clock_timestamp()
+    where id = $1
+      and deleted_at is null")
+
+(defn delete-file
+  [conn {:keys [id] :as params}]
+  (let [sql sql:delete-file]
+    (-> (db/query-one conn [sql id])
+        (p/then' su/constantly-nil))))
diff --git a/backend/src/uxbox/services/mutations/project_pages.clj b/backend/src/uxbox/services/mutations/project_pages.clj
new file mode 100644
index 000000000..579b53855
--- /dev/null
+++ b/backend/src/uxbox/services/mutations/project_pages.clj
@@ -0,0 +1,190 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) 2019 Andrey Antukh <niwi@niwi.nz>
+
+(ns uxbox.services.mutations.project-pages
+  (:require
+   [clojure.spec.alpha :as s]
+   [promesa.core :as p]
+   [uxbox.db :as db]
+   [uxbox.services.mutations :as sm]
+   [uxbox.services.mutations.project-files :as files]
+   [uxbox.services.queries.project-pages :refer [decode-row]]
+   [uxbox.services.util :as su]
+   [uxbox.util.blob :as blob]
+   [uxbox.util.spec :as us]
+   [uxbox.util.sql :as sql]
+   [uxbox.util.uuid :as uuid]))
+
+;; --- Helpers & Specs
+
+;; TODO: validate `:data` and `:metadata`
+
+(s/def ::id ::us/uuid)
+(s/def ::name ::us/string)
+(s/def ::data any?)
+(s/def ::user ::us/uuid)
+(s/def ::project-id ::us/uuid)
+(s/def ::metadata any?)
+(s/def ::ordering ::us/number)
+
+;; --- Mutation: Create Page
+
+(declare create-page)
+
+(s/def ::create-project-page
+  (s/keys :req-un [::user ::file-id ::name ::ordering ::metadata ::data]
+          :opt-un [::id]))
+
+(sm/defmutation ::create-project-page
+  [{:keys [user file-id] :as params}]
+  (db/with-atomic [conn db/pool]
+    (files/check-edition-permissions! conn user file-id)
+    (create-page conn params)))
+
+(defn create-page
+  [conn {:keys [id user file-id name ordering data metadata] :as params}]
+  (let [sql "insert into project_pages (id, user_id, file_id, name,
+                                        ordering, data, metadata, version)
+             values ($1, $2, $3, $4, $5, $6, $7, 0)
+             returning *"
+        id   (or id (uuid/next))
+        data (blob/encode data)
+        mdata (blob/encode metadata)]
+    (-> (db/query-one conn [sql id user file-id name ordering data mdata])
+        (p/then' decode-row))))
+
+;; --- Mutation: Update Page
+
+(declare select-page-for-update)
+(declare update-page)
+(declare update-history)
+
+(s/def ::update-project-page-data
+  (s/keys :req-un [::id ::user ::data]))
+
+(sm/defmutation ::update-project-page-data
+  [{:keys [id user data] :as params}]
+  (db/with-atomic [conn db/pool]
+    (p/let [{:keys [version file-id]} (select-page-for-update conn id)]
+      (files/check-edition-permissions! conn user file-id)
+      (let [data (blob/encode data)
+            version (inc version)
+            params (assoc params :id id :data data :version version)]
+        (p/do! (update-page conn params)
+               (update-history conn params)
+               (select-keys params [:id :version]))))))
+
+(defn- select-page-for-update
+  [conn id]
+  (let [sql "select p.id, p.version, p.file_id
+               from project_pages as p
+              where p.id = $1
+                and deleted_at is null
+                 for update;"]
+    (-> (db/query-one conn [sql id])
+        (p/then' su/raise-not-found-if-nil))))
+
+(defn- update-page
+  [conn {:keys [id name version data metadata]}]
+  (let [sql "update project_pages
+                set version = $1,
+                    data = $2
+              where id = $3"]
+    (-> (db/query-one conn [sql version data id])
+        (p/then' su/constantly-nil))))
+
+(defn- update-history
+  [conn {:keys [user id version data]}]
+  (let [sql "insert into project_page_history (user_id, page_id, version, data)
+             values ($1, $2, $3, $4)"]
+    (-> (db/query-one conn [sql user id version data])
+        (p/then' su/constantly-nil))))
+
+;; --- Mutation: Rename Page
+
+(declare rename-page)
+
+(s/def ::rename-project-page
+  (s/keys :req-un [::id ::name ::user]))
+
+(sm/defmutation ::rename-project-page
+  [{:keys [id name user]}]
+  (db/with-atomic [conn db/pool]
+    (p/let [page (select-page-for-update conn id)]
+      (files/check-edition-permissions! conn user (:file-id page))
+      (rename-page conn (assoc page :name name)))))
+
+(defn- rename-page
+  [conn {:keys [id name] :as params}]
+  (let [sql "update project_pages
+                set name = $2
+              where id = $1
+                and deleted_at is null"]
+    (-> (db/query-one db/pool [sql id name])
+        (p/then su/constantly-nil))))
+
+;; --- Mutation: Update Page Metadata
+
+;; (s/def ::update-page-metadata
+;;   (s/keys :req-un [::user ::project-id ::name ::metadata ::id]))
+
+;; (sm/defmutation ::update-page-metadata
+;;   [{:keys [id user project-id name metadata]}]
+;;   (let [sql "update pages
+;;                 set name = $3,
+;;                     metadata = $4
+;;               where id = $1
+;;                 and user_id = $2
+;;                 and deleted_at is null
+;;              returning *"
+;;         mdata (blob/encode metadata)]
+;;     (-> (db/query-one db/pool [sql id user name mdata])
+;;         (p/then' decode-row))))
+
+;; --- Mutation: Delete Page
+
+(declare delete-page)
+
+(s/def ::delete-project-page
+  (s/keys :req-un [::user ::id]))
+
+(sm/defmutation ::delete-project-page
+  [{:keys [id user]}]
+  (db/with-atomic [conn db/pool]
+    (p/let [page (select-page-for-update conn id)]
+      (files/check-edition-permissions! conn user (:file-id page))
+      (delete-page conn id))))
+
+(defn- delete-page
+  [conn id]
+  (let [sql "update project_pages
+                set deleted_at = clock_timestamp()
+              where id = $1
+                and deleted_at is null"]
+    (-> (db/query-one conn [sql id])
+        (p/then su/constantly-nil))))
+
+;; --- Update Page History
+
+;; (defn update-page-history
+;;   [conn {:keys [user id label pinned]}]
+;;   (let [sqlv (sql/update-page-history {:user user
+;;                                        :id id
+;;                                        :label label
+;;                                        :pinned pinned})]
+;;     (some-> (db/fetch-one conn sqlv)
+;;             (decode-row))))
+
+;; (s/def ::label ::us/string)
+;; (s/def ::update-page-history
+;;   (s/keys :req-un [::user ::id ::pinned ::label]))
+
+;; (sm/defmutation :update-page-history
+;;   {:doc "Update page history"
+;;    :spec ::update-page-history}
+;;   [params]
+;;   (with-open [conn (db/connection)]
+;;     (update-page-history conn params)))
diff --git a/backend/src/uxbox/services/mutations/projects.clj b/backend/src/uxbox/services/mutations/projects.clj
index 1a408c6c2..ea6755266 100644
--- a/backend/src/uxbox/services/mutations/projects.clj
+++ b/backend/src/uxbox/services/mutations/projects.clj
@@ -13,6 +13,7 @@
    [uxbox.services.mutations :as sm]
    [uxbox.services.util :as su]
    [uxbox.util.blob :as blob]
+   [uxbox.util.exceptions :as ex]
    [uxbox.util.uuid :as uuid]))
 
 ;; --- Helpers & Specs
@@ -22,6 +23,27 @@
 (s/def ::token ::us/string)
 (s/def ::user ::us/uuid)
 
+;; --- Permissions Checks
+
+(def ^:private sql:project-permissions
+  "select p.id,
+          pu.can_edit as can_edit
+     from projects as p
+    inner join project_users as pu
+       on (pu.project_id = p.id)
+    where pu.user_id = $1
+      and p.id = $2
+      for update of p;")
+
+(defn check-edition-permissions!
+  [conn user project-id]
+  (-> (db/query-one conn [sql:project-permissions user project-id])
+      (p/then' su/raise-not-found-if-nil)
+      (p/then' (fn [{:keys [id can-edit] :as proj}]
+                 (when-not can-edit
+                   (ex/raise :type :validation
+                             :code :not-authorized))))))
+
 ;; --- Mutation: Create Project
 
 (declare create-project)
@@ -31,11 +53,9 @@
           :opt-un [::id]))
 
 (sm/defmutation ::create-project
-  [{:keys [id user name] :as params}]
-  (let [id (or id (uuid/next))
-        sql "insert into projects (id, user_id, name)
-             values ($1, $2, $3) returning *"]
-    (db/query-one db/pool [sql id user name])))
+  [params]
+  (db/with-atomic [conn db/pool]
+    (create-project conn params)))
 
 (defn create-project
   [conn {:keys [id user name] :as params}]
@@ -46,32 +66,49 @@
 
 ;; --- Mutation: Update Project
 
+(declare update-project)
+
 (s/def ::update-project
   (s/keys :req-un [::user ::name ::id]))
 
 (sm/defmutation ::update-project
-  [{:keys [id name user] :as params}]
+  [{:keys [id user] :as params}]
+  (db/with-atomic [conn db/pool]
+    (check-edition-permissions! conn user id)
+    (update-project conn params)))
+
+(defn update-project
+  [conn {:keys [id name user] :as params}]
   (let [sql "update projects
                 set name = $3
               where id = $1
                 and user_id = $2
                 and deleted_at is null
              returning *"]
-    (db/query-one db/pool [sql id user name])))
+    (db/query-one conn [sql id user name])))
 
 ;; --- Mutation: Delete Project
 
+(declare delete-project)
+
 (s/def ::delete-project
   (s/keys :req-un [::id ::user]))
 
 (sm/defmutation ::delete-project
   [{:keys [id user] :as params}]
-  (let [sql "update projects
-                set deleted_at = clock_timestamp()
-              where id = $1
-                and user_id = $2
-                and deleted_at is null
-             returning id"]
-    (-> (db/query-one db/pool [sql id user])
-        (p/then' su/raise-not-found-if-nil)
+  (db/with-atomic [conn db/pool]
+    (check-edition-permissions! conn user id)
+    (delete-project conn params)))
+
+(def ^:private sql:delete-project
+  "update projects
+      set deleted_at = clock_timestamp()
+    where id = $1
+      and deleted_at is null
+   returning id")
+
+(defn delete-project
+  [conn {:keys [id user] :as params}]
+  (let [sql sql:delete-project]
+    (-> (db/query-one conn [sql id])
         (p/then' su/constantly-nil))))
diff --git a/backend/src/uxbox/services/mutations/user_storage.clj b/backend/src/uxbox/services/mutations/user_attrs.clj
similarity index 75%
rename from backend/src/uxbox/services/mutations/user_storage.clj
rename to backend/src/uxbox/services/mutations/user_attrs.clj
index 9328fbb5c..9d459ebb6 100644
--- a/backend/src/uxbox/services/mutations/user_storage.clj
+++ b/backend/src/uxbox/services/mutations/user_attrs.clj
@@ -4,14 +4,14 @@
 ;;
 ;; Copyright (c) 2019 Andrey Antukh <niwi@niwi.nz>
 
-(ns uxbox.services.mutations.user-storage
+(ns uxbox.services.mutations.user-attrs
   (:require
    [clojure.spec.alpha :as s]
    [promesa.core :as p]
    [uxbox.db :as db]
    [uxbox.services.mutations :as sm]
    [uxbox.services.util :as su]
-   [uxbox.services.queries.user-storage :refer [decode-row]]
+   [uxbox.services.queries.user-attrs :refer [decode-row]]
    [uxbox.util.blob :as blob]
    [uxbox.util.spec :as us]))
 
@@ -21,12 +21,12 @@
 (s/def ::key ::us/string)
 (s/def ::val any?)
 
-(s/def ::upsert-user-storage-entry
+(s/def ::upsert-user-attr
   (s/keys :req-un [::key ::val ::user]))
 
-(sm/defmutation ::upsert-user-storage-entry
+(sm/defmutation ::upsert-user-attr
   [{:keys [key val user] :as params}]
-  (let [sql "insert into user_storage (key, val, user_id)
+  (let [sql "insert into user_attrs (key, val, user_id)
              values ($1, $2, $3)
                  on conflict (user_id, key)
                  do update set val = $2"
@@ -36,12 +36,12 @@
 
 ;; --- Delete KVStore
 
-(s/def ::delete-user-storage-entry
+(s/def ::delete-user-attr
   (s/keys :req-un [::key ::user]))
 
-(sm/defmutation ::delete-user-storage-entry
+(sm/defmutation ::delete-user-attr
   [{:keys [user key] :as params}]
-  (let [sql "delete from user_storage
+  (let [sql "delete from user_attrs
               where user_id = $2
                 and key = $1"]
     (-> (db/query-one db/pool [sql key user])
diff --git a/backend/src/uxbox/services/mutations/profiles.clj b/backend/src/uxbox/services/mutations/users.clj
similarity index 94%
rename from backend/src/uxbox/services/mutations/profiles.clj
rename to backend/src/uxbox/services/mutations/users.clj
index af221e7db..308775ff9 100644
--- a/backend/src/uxbox/services/mutations/profiles.clj
+++ b/backend/src/uxbox/services/mutations/users.clj
@@ -4,7 +4,7 @@
 ;;
 ;; Copyright (c) 2016 Andrey Antukh <niwi@niwi.nz>
 
-(ns uxbox.services.mutations.profiles
+(ns uxbox.services.mutations.users
   (:require
    [buddy.hashers :as hashers]
    [clojure.spec.alpha :as s]
@@ -19,10 +19,10 @@
    [uxbox.media :as media]
    [uxbox.services.mutations :as sm]
    [uxbox.services.util :as su]
-   [uxbox.services.queries.profiles :refer [get-profile
-                                            decode-profile-row
-                                            strip-private-attrs
-                                            resolve-thumbnail]]
+   [uxbox.services.queries.users :refer [get-profile
+                                         decode-profile-row
+                                         strip-private-attrs
+                                         resolve-thumbnail]]
    [uxbox.util.blob :as blob]
    [uxbox.util.exceptions :as ex]
    [uxbox.util.spec :as us]
@@ -56,7 +56,7 @@
                     and id != $1
                   ) as val"]
     (p/let [res1 (db/query-one conn [sql1 id username])
-            res2 (db/query-one conn [sql2 id email])]
+             res2 (db/query-one conn [sql2 id email])]
       (when (:val res1)
         (ex/raise :type :validation
                   :code ::username-already-exists))
@@ -83,9 +83,7 @@
 (s/def ::update-profile
   (s/keys :req-un [::id ::username ::email ::fullname ::metadata]))
 
-(sm/defmutation :update-profile
-  {:doc "Update self profile."
-   :spec ::update-profile}
+(sm/defmutation ::update-profile
   [params]
   (db/with-atomic [conn db/pool]
     (-> (p/resolved params)
@@ -134,9 +132,7 @@
 (def valid-image-types?
   #{"image/jpeg", "image/png", "image/webp"})
 
-(sm/defmutation :update-profile-photo
-  {:doc "Update profile photo."
-   :spec ::update-profile-photo}
+(sm/defmutation ::update-profile-photo
   [{:keys [user file] :as params}]
   (letfn [(store-photo [{:keys [name path] :as upload}]
             (let [filename (fs/name name)
@@ -149,16 +145,17 @@
                           set photo = $1
                         where id = $2
                           and deleted_at is null
-                       returning *"]
+                       returning id, photo"]
               (-> (db/query-one db/pool [sql (str path) user])
                   (p/then' su/raise-not-found-if-nil)
-                  (p/then' strip-private-attrs)
+                  ;; (p/then' strip-private-attrs)
                   (p/then resolve-thumbnail))))]
 
     (when-not (valid-image-types? (:mtype file))
       (ex/raise :type :validation
                 :code :image-type-not-allowed
                 :hint "Seems like you are uploading an invalid image."))
+
     (-> (store-photo file)
         (p/then update-user-photo))))
 
diff --git a/backend/src/uxbox/services/queries.clj b/backend/src/uxbox/services/queries.clj
index bfa48b6d2..97639d2c0 100644
--- a/backend/src/uxbox/services/queries.clj
+++ b/backend/src/uxbox/services/queries.clj
@@ -11,6 +11,7 @@
 (uds/defservice handle
   {:dispatch-by ::type
    :interceptors [uds/spec-interceptor
+                  uds/wrap-errors
                   #_logging-interceptor
                   #_context-interceptor]})
 
diff --git a/backend/src/uxbox/services/queries/pages.clj b/backend/src/uxbox/services/queries/pages.clj
deleted file mode 100644
index d432d4bce..000000000
--- a/backend/src/uxbox/services/queries/pages.clj
+++ /dev/null
@@ -1,92 +0,0 @@
-;; This Source Code Form is subject to the terms of the Mozilla Public
-;; License, v. 2.0. If a copy of the MPL was not distributed with this
-;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
-;;
-;; Copyright (c) 2019 Andrey Antukh <niwi@niwi.nz>
-
-(ns uxbox.services.queries.pages
-  (:require
-   [clojure.spec.alpha :as s]
-   [promesa.core :as p]
-   [uxbox.db :as db]
-   [uxbox.services.queries :as sq]
-   [uxbox.util.blob :as blob]
-   [uxbox.util.spec :as us]
-   [uxbox.util.sql :as sql]))
-
-;; --- Helpers & Specs
-
-(declare decode-row)
-
-(s/def ::id ::us/uuid)
-(s/def ::user ::us/uuid)
-(s/def ::project-id ::us/uuid)
-
-;; --- Query: Pages by Project
-
-(s/def ::pages-by-project
-  (s/keys :req-un [::user ::project-id]))
-
-(sq/defquery ::pages-by-project
-  [{:keys [user project-id] :as params}]
-  (let [sql "select pg.*,
-                    pg.data,
-                    pg.metadata
-               from pages as pg
-              where pg.user_id = $2
-                and pg.project_id = $1
-                and pg.deleted_at is null
-              order by pg.created_at asc;"]
-    (-> (db/query db/pool [sql project-id user])
-        (p/then #(mapv decode-row %)))))
-
-;; --- Query: Page by Id
-
-(s/def ::page
-  (s/keys :req-un [::user ::id]))
-
-(sq/defquery ::page
-  [{:keys [user id] :as params}]
-  (let [sql "select pg.*,
-                    pg.data,
-                    pg.metadata
-               from pages as pg
-              where pg.user_id = $2
-                and pg.id = $1
-                and pg.deleted_at is null"]
-    (-> (db/query-one db/pool [sql id user])
-        (p/then' decode-row))))
-
-;; --- Query: Page History
-
-(s/def ::page-id ::us/uuid)
-(s/def ::max ::us/integer)
-(s/def ::pinned ::us/boolean)
-(s/def ::since ::us/integer)
-
-(s/def ::page-history
-  (s/keys :req-un [::page-id ::user]
-          :opt-un [::max ::pinned ::since]))
-
-(sq/defquery ::page-history
-  [{:keys [page-id user since max pinned] :or {since Long/MAX_VALUE max 10}}]
-  (let [sql (-> (sql/from ["pages_history" "ph"])
-                (sql/select "ph.*")
-                (sql/where ["ph.user_id = ?" user]
-                           ["ph.page_id = ?" page-id]
-                           ["ph.version < ?" since]
-                           (when pinned
-                             ["ph.pinned = ?" true]))
-                (sql/order "ph.version desc")
-                (sql/limit max))]
-    (-> (db/query db/pool (sql/fmt sql))
-        (p/then (partial mapv decode-row)))))
-
-;; --- Helpers
-
-(defn decode-row
-  [{:keys [data metadata] :as row}]
-  (when row
-    (cond-> row
-      data (assoc :data (blob/decode data))
-      metadata (assoc :metadata (blob/decode metadata)))))
diff --git a/backend/src/uxbox/services/queries/project_files.clj b/backend/src/uxbox/services/queries/project_files.clj
new file mode 100644
index 000000000..1e3effa59
--- /dev/null
+++ b/backend/src/uxbox/services/queries/project_files.clj
@@ -0,0 +1,55 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) 2019 Andrey Antukh <niwi@niwi.nz>
+
+(ns uxbox.services.queries.project-files
+  (:require
+   [clojure.spec.alpha :as s]
+   [promesa.core :as p]
+   [uxbox.db :as db]
+   [uxbox.services.queries :as sq]
+   [uxbox.util.blob :as blob]
+   [uxbox.util.spec :as us]))
+
+(declare decode-row)
+
+;; --- Helpers & Specs
+
+(s/def ::id ::us/uuid)
+(s/def ::name ::us/string)
+(s/def ::project-id ::us/uuid)
+(s/def ::user ::us/uuid)
+
+;; --- Query: Project Files
+
+(def ^:private sql:project-files
+  "select pf.*,
+          array_agg(pp.id) as pages
+     from project_files as pf
+    inner join projects as p on (pf.project_id = p.id)
+    inner join project_users as pu on (p.id = pu.project_id)
+     left join project_pages as pp on (pf.id = pp.file_id)
+    where pu.user_id = $1
+      and pu.project_id = $2
+      and pu.can_edit = true
+    group by pf.id
+    order by pf.created_at asc;")
+
+(s/def ::project-files
+  (s/keys :req-un [::user ::project-id]))
+
+(sq/defquery ::project-files
+  [{:keys [user project-id] :as params}]
+  (-> (db/query db/pool [sql:project-files user project-id])
+      (p/then' (partial mapv decode-row))))
+
+;; --- Helpers
+
+(defn decode-row
+  [{:keys [metadata pages] :as row}]
+  (when row
+    (cond-> row
+      pages (assoc :pages (vec (remove nil? pages)))
+      metadata (assoc :metadata (blob/decode metadata)))))
diff --git a/backend/src/uxbox/services/queries/project_pages.clj b/backend/src/uxbox/services/queries/project_pages.clj
new file mode 100644
index 000000000..3ba860c5f
--- /dev/null
+++ b/backend/src/uxbox/services/queries/project_pages.clj
@@ -0,0 +1,145 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) 2019 Andrey Antukh <niwi@niwi.nz>
+
+(ns uxbox.services.queries.project-pages
+  (:require
+   [clojure.spec.alpha :as s]
+   [promesa.core :as p]
+   [uxbox.db :as db]
+   [uxbox.services.queries :as sq]
+   [uxbox.services.util :as su]
+   [uxbox.util.blob :as blob]
+   [uxbox.util.spec :as us]
+   [uxbox.util.sql :as sql]))
+
+;; --- Helpers & Specs
+
+(declare decode-row)
+
+(s/def ::id ::us/uuid)
+(s/def ::user ::us/uuid)
+(s/def ::project-id ::us/uuid)
+(s/def ::file-id ::us/uuid)
+
+(def ^:private sql:generic-project-pages
+  "select pp.*
+     from project_pages as pp
+    inner join project_files as pf on (pf.id = pp.file_id)
+    inner join projects as p on (p.id = pf.project_id)
+     left join project_users as pu on (pu.project_id = p.id)
+     left join project_file_users as pfu on (pfu.file_id = pf.id)
+    where ((pfu.user_id = $1 and pfu.can_edit = true) or
+           (pu.user_id = $1 and pu.can_edit = true))
+    order by pp.created_at")
+
+;; --- Query: Project Pages (By File ID)
+
+(def ^:private sql:project-pages
+  (str "with pages as (" sql:generic-project-pages ")"
+       " select * from pages where file_id = $2"))
+
+;; (defn project-pages-sql
+;;   [user]
+;;   (-> (sql/from ["project_pages" "pp"])
+;;       (sql/join ["project_files" "pf"] "pf.id = pp.file_id")
+;;       (sql/join ["projects" "p"] "p.id = pf.project_id")
+;;       (sql/ljoin ["project_users", "pu"] "pu.project_id = p.id")
+;;       (sql/ljoin ["project_file_users", "pfu"] "pfu.file_id = pf.id")
+;;       (sql/select "pp.*")
+;;       (sql/where ["((pfu.user_id = ? and pfu.can_edit = true) or
+;;                  (pu.user_id = ? and pu.can_edit = true))" user user])
+;;       (sql/order "pp.created_at")))
+
+;; (let [sql (-> (project-pages-sql user)
+;;               (sql/where ["pp.file_id = ?" file-id])
+;;               (sql/fmt))]
+;;   (-> (db/query db/pool sql)
+;;       (p/then #(mapv decode-row %)))))
+
+(s/def ::project-pages
+  (s/keys :req-un [::user ::file-id]))
+
+(sq/defquery ::project-pages
+  [{:keys [user file-id] :as params}]
+  (let [sql sql:project-pages]
+    (-> (db/query db/pool [sql user file-id])
+        (p/then #(mapv decode-row %)))))
+
+;; --- Query: Project Page (By ID)
+
+(def ^:private sql:project-page
+  (str "with pages as (" sql:generic-project-pages ")"
+       " select * from pages where id = $2"))
+
+(defn retrieve-page
+  [conn {:keys [user id] :as params}]
+  (let [sql sql:project-page]
+    (-> (db/query-one conn [sql user id])
+        (p/then' su/raise-not-found-if-nil)
+        (p/then' decode-row))))
+
+(s/def ::project-page
+  (s/keys :req-un [::user ::id]))
+
+(sq/defquery ::project-page
+  [{:keys [user id] :as params}]
+  (retrieve-page db/pool params))
+
+;; --- Query: Project Page History (by Page ID)
+
+;; (def ^:private sql:generic-page-history
+;;   "select pph.*
+;;      from project_page_history as pph
+;;     where pph.page_id = $2
+;;       and pph.version < $3
+;;     order by pph.version < desc")
+
+;; (def ^:private sql:page-history
+;;   (str "with history as (" sql:generic-page-history ")"
+;;        " select * from history limit $4"))
+
+;; (def ^:private sql:pinned-page-history
+;;   (str "with history as (" sql:generic-page-history ")"
+;;        " select * from history where pinned = true limit $4"))
+
+(s/def ::page-id ::us/uuid)
+(s/def ::max ::us/integer)
+(s/def ::pinned ::us/boolean)
+(s/def ::since ::us/integer)
+
+(s/def ::page-history
+  (s/keys :req-un [::page-id ::user]
+          :opt-un [::max ::pinned ::since]))
+
+(defn retrieve-page-history
+  [{:keys [page-id user since max pinned] :or {since Long/MAX_VALUE max 10}}]
+  (let [sql (-> (sql/from ["pages_history" "ph"])
+                (sql/select "ph.*")
+                (sql/where ["ph.user_id = ?" user]
+                           ["ph.page_id = ?" page-id]
+                           ["ph.version < ?" since]
+                           (when pinned
+                             ["ph.pinned = ?" true]))
+                (sql/order "ph.version desc")
+                (sql/limit max))]
+    (-> (db/query db/pool (sql/fmt sql))
+        (p/then (partial mapv decode-row)))))
+
+(sq/defquery ::page-history
+  [{:keys [page-id user] :as params}]
+  (db/with-atomic [conn db/pool]
+    (p/do! (retrieve-page conn {:id page-id :user user})
+           (retrieve-page-history conn params))))
+
+
+;; --- Helpers
+
+(defn decode-row
+  [{:keys [data metadata] :as row}]
+  (when row
+    (cond-> row
+      data (assoc :data (blob/decode data))
+      metadata (assoc :metadata (blob/decode metadata)))))
diff --git a/backend/src/uxbox/services/queries/projects.clj b/backend/src/uxbox/services/queries/projects.clj
index c3cfa47f6..392b227ed 100644
--- a/backend/src/uxbox/services/queries/projects.clj
+++ b/backend/src/uxbox/services/queries/projects.clj
@@ -13,6 +13,8 @@
    [uxbox.util.blob :as blob]
    [uxbox.util.spec :as us]))
 
+(declare decode-row)
+
 ;; --- Helpers & Specs
 
 (s/def ::id ::us/uuid)
@@ -22,19 +24,26 @@
 
 ;; --- Query: Projects
 
+;; (def ^:private projects-sql
+;;   "select distinct on (p.id, p.created_at)
+;;           p.*,
+;;           array_agg(pg.id) over (
+;;             partition by p.id
+;;             order by pg.created_at
+;;             range between unbounded preceding and unbounded following
+;;           ) as pages
+;;     from projects as p
+;;     left join pages as pg
+;;            on (pg.project_id = p.id)
+;;    where p.user_id = $1
+;;    order by p.created_at asc")
+
 (def ^:private projects-sql
-  "select distinct on (p.id, p.created_at)
-          p.*,
-          array_agg(pg.id) over (
-            partition by p.id
-            order by pg.created_at
-            range between unbounded preceding and unbounded following
-          ) as pages
-    from projects as p
-    left join pages as pg
-           on (pg.project_id = p.id)
-   where p.user_id = $1
-   order by p.created_at asc")
+  "select p.*
+     from project_users as pu
+    inner join projects as p on (p.id = pu.project_id)
+    where pu.can_edit = true
+      and pu.user_id = $1;")
 
 (s/def ::projects
   (s/keys :req-un [::user]))
@@ -42,5 +51,12 @@
 (sq/defquery ::projects
   [{:keys [user] :as params}]
   (-> (db/query db/pool [projects-sql user])
-      (p/then (fn [rows]
-                (mapv #(update % :pages vec) rows)))))
+      (p/then' (partial mapv decode-row))))
+
+;; --- Helpers
+
+(defn decode-row
+  [{:keys [metadata] :as row}]
+  (when row
+    (cond-> row
+      metadata (assoc :metadata (blob/decode metadata)))))
diff --git a/backend/src/uxbox/services/queries/user_storage.clj b/backend/src/uxbox/services/queries/user_attrs.clj
similarity index 85%
rename from backend/src/uxbox/services/queries/user_storage.clj
rename to backend/src/uxbox/services/queries/user_attrs.clj
index 49a316959..4498e2a46 100644
--- a/backend/src/uxbox/services/queries/user_storage.clj
+++ b/backend/src/uxbox/services/queries/user_attrs.clj
@@ -4,7 +4,7 @@
 ;;
 ;; Copyright (c) 2019 Andrey Antukh <niwi@niwi.nz>
 
-(ns uxbox.services.queries.user-storage
+(ns uxbox.services.queries.user-attrs
   (:require
    [clojure.spec.alpha :as s]
    [promesa.core :as p]
@@ -20,13 +20,13 @@
     (cond-> row
       val (assoc :val (blob/decode val)))))
 
-(s/def ::user-storage-entry
+(s/def ::user-attr
   (s/keys :req-un [::key ::user]))
 
-(sq/defquery ::user-storage-entry
+(sq/defquery ::user-attr
   [{:keys [key user]}]
   (let [sql "select kv.*
-               from user_storage as kv
+               from user_attrs as kv
               where kv.user_id = $2
                 and kv.key = $1"]
     (-> (db/query-one db/pool [sql key user])
diff --git a/backend/src/uxbox/services/queries/profiles.clj b/backend/src/uxbox/services/queries/users.clj
similarity index 94%
rename from backend/src/uxbox/services/queries/profiles.clj
rename to backend/src/uxbox/services/queries/users.clj
index 52c72a7b5..55982c859 100644
--- a/backend/src/uxbox/services/queries/profiles.clj
+++ b/backend/src/uxbox/services/queries/users.clj
@@ -4,7 +4,7 @@
 ;;
 ;; Copyright (c) 2016 Andrey Antukh <niwi@niwi.nz>
 
-(ns uxbox.services.queries.profiles
+(ns uxbox.services.queries.users
   (:require
    [clojure.spec.alpha :as s]
    [promesa.core :as p]
@@ -51,9 +51,7 @@
 (s/def ::profile
   (s/keys :req-un [::user]))
 
-(sq/defquery :profile
-  {:doc "Retrieve the user profile."
-   :spec ::profile}
+(sq/defquery ::profile
   [{:keys [user] :as params}]
   (-> (get-profile db/pool user)
       (p/then' strip-private-attrs)))
diff --git a/backend/src/uxbox/util/dispatcher.clj b/backend/src/uxbox/util/dispatcher.clj
index 75b6fc3fe..db9588b54 100644
--- a/backend/src/uxbox/util/dispatcher.clj
+++ b/backend/src/uxbox/util/dispatcher.clj
@@ -28,7 +28,7 @@
   IDispatcher
   (add [this key f metadata]
     (.put ^Map reg key (MapEntry/create f metadata))
-    nil)
+    this)
 
   clojure.lang.IDeref
   (deref [_]
@@ -56,7 +56,7 @@
 
 (defn dispatcher?
   [v]
-  (instance? Dispatcher v))
+  (instance? IDispatcher v))
 
 (defmacro defservice
   [sname {:keys [dispatch-by interceptors]}]
@@ -118,5 +118,16 @@
                                 :code :spec-validation
                                 :explain (with-out-str
                                            (expound/printer data))
-                                :data data))))
+                                :data (::s/problems data)))))
                 data)))})
+
+(def wrap-errors
+  {:error
+   (fn [data]
+     (let [error (:error data)
+           mdata (meta (:request data))]
+       (assoc data :error (ex/error :type :service-error
+                                    :name (:spec mdata)
+                                    :cause error))))})
+
+
diff --git a/backend/src/uxbox/util/exceptions.clj b/backend/src/uxbox/util/exceptions.clj
index 70db97db5..b39a9e8ce 100644
--- a/backend/src/uxbox/util/exceptions.clj
+++ b/backend/src/uxbox/util/exceptions.clj
@@ -12,7 +12,7 @@
 (s/def ::code keyword?)
 (s/def ::mesage string?)
 (s/def ::hint string?)
-(s/def ::cause #(instance? Exception %))
+(s/def ::cause #(instance? Throwable %))
 (s/def ::error-params
   (s/keys :req-un [::type]
           :opt-un [::code
diff --git a/backend/src/uxbox/util/http.clj b/backend/src/uxbox/util/http.clj
index 3fd4b4561..4cbc1fb78 100644
--- a/backend/src/uxbox/util/http.clj
+++ b/backend/src/uxbox/util/http.clj
@@ -4,213 +4,5 @@
 ;;
 ;; Copyright (c) 2019 Andrey Antukh <niwi@niwi.nz>
 
-(ns uxbox.util.http)
-
-(defn response
-  "Create a response instance."
-  ([body] (response body 200 {}))
-  ([body status] (response body status {}))
-  ([body status headers] {:body body :status status :headers headers}))
-
-(defn response?
-  [resp]
-  (and (map? resp)
-       (integer? (:status resp))
-       (map? (:headers resp))))
-
-(defn continue
-  ([body] (response body 100))
-  ([body headers] (response body 100 headers)))
-
-(defn ok
-  "HTTP 200 OK
-  Should be used to indicate nonspecific success. Must not be used to
-  communicate errors in the response body.
-
-  In most cases, 200 is the code the client hopes to see. It indicates that
-  the REST API successfully carried out whatever action the client requested,
-  and that no more specific code in the 2xx series is appropriate. Unlike
-  the 204 status code, a 200 response should include a response body."
-  ([body] (response body 200))
-  ([body headers] (response body 200 headers)))
-
-(defn created
-  "HTTP 201 Created
-  Must be used to indicate successful resource creation.
-
-  A REST API responds with the 201 status code whenever a collection creates,
-  or a store adds, a new resource at the client's request. There may also be
-  times when a new resource is created as a result of some controller action,
-  in which case 201 would also be an appropriate response."
-  ([location] (response "" 201 {"location" location}))
-  ([location body] (response body 201 {"location" location}))
-  ([location body headers] (response body 201 (merge headers {"location" location}))))
-
-(defn accepted
-  "HTTP 202 Accepted
-  Must be used to indicate successful start of an asynchronous action.
-
-  A 202 response indicates that the client's request will be handled
-  asynchronously. This response status code tells the client that the request
-  appears valid, but it still may have problems once it's finally processed.
-  A 202 response is typically used for actions that take a long while to
-  process."
-  ([body] (response body 202))
-  ([body headers] (response body 202 headers)))
-
-(defn no-content
-  "HTTP 204 No Content
-  Should be used when the response body is intentionally empty.
-
-  The 204 status code is usually sent out in response to a PUT, POST, or
-  DELETE request, when the REST API declines to send back any status message
-  or representation in the response message's body. An API may also send 204
-  in conjunction with a GET request to indicate that the requested resource
-  exists, but has no state representation to include in the body."
-  ([] (response "" 204))
-  ([headers] (response "" 204 headers)))
-
-(defn moved-permanently
-  "301 Moved Permanently
-  Should be used to relocate resources.
-
-  The 301 status code indicates that the REST API's resource model has been
-  significantly redesigned and a new permanent URI has been assigned to the
-  client's requested resource. The REST API should specify the new URI in
-  the response's Location header."
-  ([location] (response "" 301 {"location" location}))
-  ([location body] (response body 301 {"location" location}))
-  ([location body headers] (response body 301 (merge headers {"location" location}))))
-
-(defn found
-  "HTTP 302 Found
-  Should not be used.
-
-  The intended semantics of the 302 response code have been misunderstood
-  by programmers and incorrectly implemented in programs since version 1.0
-  of the HTTP protocol.
-  The confusion centers on whether it is appropriate for a client to always
-  automatically issue a follow-up GET request to the URI in response's
-  Location header, regardless of the original request's method. For the
-  record, the intent of 302 is that this automatic redirect behavior only
-  applies if the client's original request used either the GET or HEAD
-  method.
-
-  To clear things up, HTTP 1.1 introduced status codes 303 (\"See Other\")
-  and 307 (\"Temporary Redirect\"), either of which should be used
-  instead of 302."
-  ([location] (response "" 302 {"location" location}))
-  ([location body] (response body 302 {"location" location}))
-  ([location body headers] (response body 302 (merge headers {"location" location}))))
-
-(defn see-other
-  "HTTP 303 See Other
-  Should be used to refer the client to a different URI.
-
-  A 303 response indicates that a controller resource has finished its work,
-  but instead of sending a potentially unwanted response body, it sends the
-  client the URI of a response resource. This can be the URI of a temporary
-  status message, or the URI to some already existing, more permanent,
-  resource.
-  Generally speaking, the 303 status code allows a REST API to send a
-  reference to a resource without forcing the client to download its state.
-  Instead, the client may send a GET request to the value of the Location
-  header."
-  ([location] (response "" 303 {"location" location}))
-  ([location body] (response body 303 {"location" location}))
-  ([location body headers] (response body 303 (merge headers {"location" location}))))
-
-(defn temporary-redirect
-  "HTTP 307 Temporary Redirect
-  Should be used to tell clients to resubmit the request to another URI.
-
-  HTTP/1.1 introduced the 307 status code to reiterate the originally
-  intended semantics of the 302 (\"Found\") status code. A 307 response
-  indicates that the REST API is not going to process the client's request.
-  Instead, the client should resubmit the request to the URI specified by
-  the response message's Location header.
-
-  A REST API can use this status code to assign a temporary URI to the
-  client's requested resource. For example, a 307 response can be used to
-  shift a client request over to another host."
-  ([location] (response "" 307 {"location" location}))
-  ([location body] (response body 307 {"location" location}))
-  ([location body headers] (response body 307 (merge headers {"location" location}))))
-
-(defn bad-request
-  "HTTP 400 Bad Request
-  May be used to indicate nonspecific failure.
-
-  400 is the generic client-side error status, used when no other 4xx error
-  code is appropriate."
-  ([body] (response body 400))
-  ([body headers] (response body 400 headers)))
-
-(defn unauthorized
-  "HTTP 401 Unauthorized
-  Must be used when there is a problem with the client credentials.
-
-  A 401 error response indicates that the client tried to operate on a
-  protected resource without providing the proper authorization. It may have
-  provided the wrong credentials or none at all."
-  ([body] (response body 401))
-  ([body headers] (response body 401 headers)))
-
-(defn forbidden
-  "HTTP 403 Forbidden
-  Should be used to forbid access regardless of authorization state.
-
-  A 403 error response indicates that the client's request is formed
-  correctly, but the REST API refuses to honor it. A 403 response is not a
-  case of insufficient client credentials; that would be 401 (\"Unauthorized\").
-  REST APIs use 403 to enforce application-level permissions. For example, a
-  client may be authorized to interact with some, but not all of a REST API's
-  resources. If the client attempts a resource interaction that is outside of
-  its permitted scope, the REST API should respond with 403."
-  ([body] (response body 403))
-  ([body headers] (response body 403 headers)))
-
-(defn not-found
-  "HTTP 404 Not Found
-  Must be used when a client's URI cannot be mapped to a resource.
-
-  The 404 error status code indicates that the REST API can't map the
-  client's URI to a resource."
-  ([body] (response body 404))
-  ([body headers] (response body 404 headers)))
-
-(defn method-not-allowed
-  ([body] (response body 405))
-  ([body headers] (response body 405 headers)))
-
-(defn not-acceptable
-  ([body] (response body 406))
-  ([body headers] (response body 406 headers)))
-
-(defn conflict
-  ([body] (response body 409))
-  ([body headers] (response body 409 headers)))
-
-(defn gone
-  ([body] (response body 410))
-  ([body headers] (response body 410 headers)))
-
-(defn precondition-failed
-  ([body] (response body 412))
-  ([body headers] (response body 412 headers)))
-
-(defn unsupported-mediatype
-  ([body] (response body 415))
-  ([body headers] (response body 415 headers)))
-
-(defn too-many-requests
-  ([body] (response body 429))
-  ([body headers] (response body 429 headers)))
-
-(defn internal-server-error
-  ([body] (response body 500))
-  ([body headers] (response body 500 headers)))
-
-(defn not-implemented
-  ([body] (response body 501))
-  ([body headers] (response body 501 headers)))
+(ns uxbox.util.http
+  "Http related helpers.")
diff --git a/backend/src/uxbox/util/sql.clj b/backend/src/uxbox/util/sql.clj
index 44bef679d..48b4cab58 100644
--- a/backend/src/uxbox/util/sql.clj
+++ b/backend/src/uxbox/util/sql.clj
@@ -157,7 +157,9 @@
                 (into rp p)
                 (first n)
                 (rest n)))
-       [(str prefix (str/join join-with rs) suffix) rp]))))
+       (if (empty? rs)
+         ["" []]
+         [(str prefix (str/join join-with rs) suffix) rp])))))
 
 (defn- process-param-tokens
   [sql]
@@ -168,7 +170,7 @@
 (def ^:private select-formatters
   [#(format-exprs (::select %) {:prefix "SELECT "})
    #(format-exprs (::from %) {:prefix "FROM "})
-   #(format-exprs (::join %))
+   #(format-exprs (::join %) {:join-with " "})
    #(format-exprs (::where %) {:prefix "WHERE ("
                                :join-with ") AND ("
                                :suffix ")"})
diff --git a/backend/test/uxbox/tests/helpers.clj b/backend/test/uxbox/tests/helpers.clj
index 8e75991c4..ea2bda316 100644
--- a/backend/test/uxbox/tests/helpers.clj
+++ b/backend/test/uxbox/tests/helpers.clj
@@ -6,9 +6,10 @@
    [cuerdas.core :as str]
    [mount.core :as mount]
    [datoteka.storages :as st]
-   [uxbox.services.mutations.profiles :as profiles]
+   [uxbox.services.mutations.users :as users]
    [uxbox.services.mutations.projects :as projects]
-   [uxbox.services.mutations.pages :as pages]
+   [uxbox.services.mutations.project-files :as files]
+   [uxbox.services.mutations.project-pages :as pages]
    [uxbox.fixtures :as fixtures]
    [uxbox.migrations]
    [uxbox.media]
@@ -24,6 +25,8 @@
                       #'uxbox.config/secret
                       #'uxbox.core/system
                       #'uxbox.db/pool
+                      #'uxbox.services.init/query-services
+                      #'uxbox.services.init/mutation-services
                       #'uxbox.migrations/migrations
                       #'uxbox.media/assets-storage
                       #'uxbox.media/media-storage
@@ -64,39 +67,44 @@
 
 (defn create-user
   [conn i]
-  (profiles/create-profile conn {:id (mk-uuid "user" i)
-                                 :fullname (str "User " i)
-                                 :username (str "user" i)
-                                 :email (str "user" i ".test@uxbox.io")
-                                 :password "123123"
-                                 :metadata {}}))
+  (users/create-profile conn {:id (mk-uuid "user" i)
+                              :fullname (str "User " i)
+                              :username (str "user" i)
+                              :email (str "user" i ".test@uxbox.io")
+                              :password "123123"
+                              :metadata {}}))
 
 (defn create-project
   [conn user-id i]
   (projects/create-project conn {:id (mk-uuid "project" i)
                                  :user user-id
+                                 :version 1
                                  :name (str "sample project " i)}))
 
-(defn create-page
-  [conn uid pid i]
+
+(defn create-project-file
+  [conn user-id project-id i]
+  (files/create-file conn {:id (mk-uuid "project-file" i)
+                           :user user-id
+                           :project-id project-id
+                           :name (str "sample project file" i)}))
+
+
+(defn create-project-page
+  [conn user-id file-id i]
   (pages/create-page conn {:id (mk-uuid "page" i)
-                           :user uid
-                           :project-id pid
+                           :user user-id
+                           :file-id file-id
                            :name (str "page" i)
-                           :data {:shapes []}
+                           :ordering i
+                           :data {}
                            :metadata {}}))
 
 (defn handle-error
   [err]
-  (cond
-    (instance? clojure.lang.ExceptionInfo err)
-    (ex-data err)
-
-    (instance? java.util.concurrent.ExecutionException err)
+  (if (instance? java.util.concurrent.ExecutionException err)
     (handle-error (.getCause err))
-
-    :else
-    [err nil]))
+    err))
 
 (defmacro try-on
   [expr]
@@ -126,21 +134,28 @@
        {:error (handle-error e#)
         :result nil})))
 
+(defn print-error!
+  [error]
+  (let [data (ex-data error)]
+    (cond
+      (= :spec-validation (:code data))
+      (println (:explain data))
+
+      :else
+      (.printStackTrace error))))
+
 (defn print-result!
   [{:keys [error result]}]
   (if error
     (do
       (println "====> START ERROR")
-      (if (= :spec-validation (:code error))
-        (s/explain-out (:data error))
-        (prn error))
+      (print-error! error)
       (println "====> END ERROR"))
     (do
       (println "====> START RESPONSE")
       (prn result)
       (println "====> END RESPONSE"))))
 
-
 (defn exception?
   [v]
   (instance? Throwable v))
@@ -154,6 +169,11 @@
   (let [data (ex-data e)]
     (= type (:type data))))
 
+(defn ex-of-code?
+  [e code]
+  (let [data (ex-data e)]
+    (= code (:code data))))
+
 (defn ex-with-code?
   [e code]
   (let [data (ex-data e)]
diff --git a/backend/test/uxbox/tests/test_emails.clj b/backend/test/uxbox/tests/test_emails.clj
index 9e853e974..248ddb71c 100644
--- a/backend/test/uxbox/tests/test_emails.clj
+++ b/backend/test/uxbox/tests/test_emails.clj
@@ -25,20 +25,20 @@
     (t/is (contains? result :reply-to))
     (t/is (vector? (:body result)))))
 
-(t/deftest email-sending-and-sendmail-job
-  (let [res @(emails/send! emails/register {:to "example@uxbox.io" :name "foo"})]
-    (t/is (nil? res)))
-  (with-mock mock
-    {:target 'uxbox.jobs.sendmail/impl-sendmail
-     :return (p/resolved nil)}
+;; (t/deftest email-sending-and-sendmail-job
+;;   (let [res @(emails/send! emails/register {:to "example@uxbox.io" :name "foo"})]
+;;     (t/is (nil? res)))
+;;   (with-mock mock
+;;     {:target 'uxbox.jobs.sendmail/impl-sendmail
+;;      :return (p/resolved nil)}
 
-    (let [res @(uxbox.jobs.sendmail/send-emails {})]
-      (t/is (= 1 res))
-      (t/is (:called? @mock))
-      (t/is (= 1 (:call-count @mock))))
+;;     (let [res @(uxbox.jobs.sendmail/send-emails {})]
+;;       (t/is (= 1 res))
+;;       (t/is (:called? @mock))
+;;       (t/is (= 1 (:call-count @mock))))
 
-    (let [res @(uxbox.jobs.sendmail/send-emails {})]
-      (t/is (= 0 res))
-      (t/is (:called? @mock))
-      (t/is (= 1 (:call-count @mock))))))
+;;     (let [res @(uxbox.jobs.sendmail/send-emails {})]
+;;       (t/is (= 0 res))
+;;       (t/is (:called? @mock))
+;;       (t/is (= 1 (:call-count @mock))))))
 
diff --git a/backend/test/uxbox/tests/test_services_auth.clj b/backend/test/uxbox/tests/test_services_auth.clj
index 36ef17214..35ac36b80 100644
--- a/backend/test/uxbox/tests/test_services_auth.clj
+++ b/backend/test/uxbox/tests/test_services_auth.clj
@@ -24,9 +24,14 @@
                :scope "foobar"}
         out (th/try-on! (sm/handle event))]
     ;; (th/print-result! out)
-    (t/is (map? (:error out)))
-    (t/is (= (get-in out [:error :type]) :validation))
-    (t/is (= (get-in out [:error :code]) :uxbox.services.mutations.auth/wrong-credentials))))
+    (let [error (:error out)]
+      (t/is (th/ex-info? error))
+      (t/is (th/ex-of-type? error :service-error)))
+
+    (let [error (ex-cause (:error out))]
+      (t/is (th/ex-info? error))
+      (t/is (th/ex-of-type? error :validation))
+      (t/is (th/ex-of-code? error :uxbox.services.mutations.auth/wrong-credentials)))))
 
 (t/deftest success-auth
   (let [user @(th/create-user db/pool 1)
diff --git a/backend/test/uxbox/tests/test_services_pages.clj b/backend/test/uxbox/tests/test_services_pages.clj
deleted file mode 100644
index b1a5ba920..000000000
--- a/backend/test/uxbox/tests/test_services_pages.clj
+++ /dev/null
@@ -1,144 +0,0 @@
-(ns uxbox.tests.test-services-pages
-  (:require
-   [clojure.spec.alpha :as s]
-   [clojure.test :as t]
-   [promesa.core :as p]
-   [uxbox.db :as db]
-   [uxbox.http :as http]
-   [uxbox.services.mutations :as sm]
-   [uxbox.services.queries :as sq]
-   [uxbox.tests.helpers :as th]))
-
-(t/use-fixtures :once th/state-init)
-(t/use-fixtures :each th/database-reset)
-
-(t/deftest mutation-create-page
-  (let [user @(th/create-user db/pool 1)
-        proj @(th/create-project db/pool (:id user) 1)
-        data {::sm/type :create-page
-              :data {:shapes []}
-              :metadata {}
-              :project-id (:id proj)
-              :name "test page"
-              :user (:id user)}
-        res (th/try-on! (sm/handle data))]
-    (t/is (nil? (:error res)))
-    (t/is (uuid? (get-in res [:result :id])))
-    (let [rsp (:result res)]
-      (t/is (= (:user data) (:user-id rsp)))
-      (t/is (= (:name data) (:name rsp)))
-      (t/is (= (:data data) (:data rsp)))
-      (t/is (= (:metadata data) (:metadata rsp))))))
-
-(t/deftest mutation-update-page
-  (let [user @(th/create-user db/pool 1)
-        proj @(th/create-project db/pool (:id user) 1)
-        page @(th/create-page db/pool (:id user) (:id proj) 1)
-        data {::sm/type :update-page
-              :id (:id page)
-              :data {:shapes [1 2 3]}
-              :metadata {:foo 2}
-              :project-id (:id proj)
-              :name "test page"
-              :user (:id user)}
-        res (th/try-on! (sm/handle data))]
-
-    ;; (th/print-result! res)
-
-    (t/is (nil? (:error res)))
-    (t/is (= (:id data) (get-in res [:result :id])))
-    #_(t/is (= (:user data) (get-in res [:result :user-id])))
-    #_(t/is (= (:name data) (get-in res [:result :name])))
-    #_(t/is (= (:data data) (get-in res [:result :data])))
-    #_(t/is (= (:metadata data) (get-in res [:result :metadata])))))
-
-(t/deftest mutation-update-page-metadata
-  (let [user @(th/create-user db/pool 1)
-        proj @(th/create-project db/pool (:id user) 1)
-        page @(th/create-page db/pool (:id user) (:id proj) 1)
-        data {::sm/type :update-page-metadata
-              :id (:id page)
-              :metadata {:foo 2}
-              :project-id (:id proj)
-              :name "test page"
-              :user (:id user)}
-        res (th/try-on! (sm/handle data))]
-
-    ;; (th/print-result! res)
-    (t/is (nil? (:error res)))
-    (t/is (= (:id data) (get-in res [:result :id])))
-    (t/is (= (:user data) (get-in res [:result :user-id])))
-    (t/is (= (:name data) (get-in res [:result :name])))
-    (t/is (= (:metadata data) (get-in res [:result :metadata])))))
-
-(t/deftest mutation-delete-page
-  (let [user @(th/create-user db/pool 1)
-        proj @(th/create-project db/pool (:id user) 1)
-        page @(th/create-page db/pool (:id user) (:id proj) 1)
-        data {::sm/type :delete-page
-              :id (:id page)
-              :user (:id user)}
-        res (th/try-on! (sm/handle data))]
-
-    ;; (th/print-result! res)
-    (t/is (nil? (:error res)))
-    (t/is (nil? (:result res)))))
-
-(t/deftest query-pages-by-project
-  (let [user @(th/create-user db/pool 1)
-        proj @(th/create-project db/pool (:id user) 1)
-        page @(th/create-page db/pool (:id user) (:id proj) 1)
-        data {::sq/type :pages-by-project
-              :project-id (:id proj)
-              :user (:id user)}
-        res (th/try-on! (sq/handle data))]
-
-    ;; (th/print-result! res)
-    (t/is (nil? (:error res)))
-    (t/is (vector? (:result res)))
-    (t/is (= 1 (count (:result res))))
-    (t/is (= "page1" (get-in res [:result 0 :name])))
-    (t/is (:id proj) (get-in res [:result 0 :project-id]))))
-
-;; (t/deftest http-page-history-update
-;;   (with-open [conn (db/connection)]
-;;     (let [user (th/create-user conn 1)
-;;           proj (uspr/create-project conn {:user (:id user) :name "proj1"})
-;;           data {:id (uuid/random)
-;;                 :user (:id user)
-;;                 :project (:id proj)
-;;                 :version 0
-;;                 :data "1"
-;;                 :metadata "2"
-;;                 :name "page1"
-;;                 :width 200
-;;                 :height 200
-;;                 :layout "mobil"}
-;;           page (uspg/create-page conn data)]
-
-;;       (dotimes [i 10]
-;;         (let [page (uspg/get-page-by-id conn (:id data))]
-;;           (uspg/update-page conn (assoc page :data (str i)))))
-
-;;       ;; Check inserted history
-;;       (let [sql (str "SELECT * FROM pages_history "
-;;                      " WHERE page=? ORDER BY created_at DESC")
-;;             result (sc/fetch conn [sql (:id data)])
-;;             item (first result)]
-
-;;         (th/with-server {:handler @http/app}
-;;           (let [uri (str th/+base-url+
-;;                          "/api/pages/" (:id page)
-;;                          "/history/" (:id item))
-;;                 params {:body {:label "test" :pinned true}}
-;;                 [status data] (th/http-put user uri params)]
-;;             ;; (println "RESPONSE:" status data)
-;;             (t/is (= 200 status))
-;;             (t/is (= (:id data) (:id item))))))
-
-;;       (let [sql (str "SELECT * FROM pages_history "
-;;                      " WHERE page=? AND pinned = true "
-;;                      " ORDER BY created_at DESC")
-;;             result (sc/fetch-one conn [sql (:id data)])]
-;;         (t/is (= "test" (:label result)))
-;;         (t/is (= true (:pinned result)))))))
diff --git a/backend/test/uxbox/tests/test_services_project_files.clj b/backend/test/uxbox/tests/test_services_project_files.clj
new file mode 100644
index 000000000..c3ab27ddb
--- /dev/null
+++ b/backend/test/uxbox/tests/test_services_project_files.clj
@@ -0,0 +1,76 @@
+(ns uxbox.tests.test-services-project-files
+  (:require
+   [clojure.test :as t]
+   [promesa.core :as p]
+   [uxbox.db :as db]
+   [uxbox.http :as http]
+   [uxbox.services.mutations :as sm]
+   [uxbox.services.queries :as sq]
+   [uxbox.tests.helpers :as th]))
+
+(t/use-fixtures :once th/state-init)
+(t/use-fixtures :each th/database-reset)
+
+(t/deftest query-project-files
+  (let [user @(th/create-user db/pool 2)
+        proj @(th/create-project db/pool (:id user) 1)
+        pf   @(th/create-project-file db/pool (:id user) (:id proj) 1)
+        pp   @(th/create-project-page db/pool (:id user) (:id pf) 1)
+        data {::sq/type :project-files
+              :user (:id user)
+              :project-id (:id proj)}
+        out (th/try-on! (sq/handle data))]
+    ;; (th/print-result! out)
+    (t/is (nil? (:error out)))
+    (t/is (= 1 (count (:result out))))
+    (t/is (= (:id pf)   (get-in out [:result 0 :id])))
+    (t/is (= (:id proj) (get-in out [:result 0 :project-id])))
+    (t/is (= (:name pf) (get-in out [:result 0 :name])))
+    (t/is (= [(:id pp)] (get-in out [:result 0 :pages])))))
+
+(t/deftest mutation-create-project-file
+  (let [user @(th/create-user db/pool 1)
+        proj @(th/create-project db/pool (:id user) 1)
+        data {::sm/type :create-project-file
+              :user (:id user)
+              :name "test file"
+              :project-id (:id proj)}
+        out (th/try-on! (sm/handle data))
+        ]
+    ;; (th/print-result! out)
+    (t/is (nil? (:error out)))
+    (t/is (= (:name data) (get-in out [:result :name])))
+    #_(t/is (= (:project-id data) (get-in out [:result :project-id])))))
+
+(t/deftest mutation-update-project-file
+  (let [user @(th/create-user db/pool 1)
+        proj @(th/create-project db/pool (:id user) 1)
+        pf   @(th/create-project-file db/pool (:id user) (:id proj) 1)
+        data {::sm/type :update-project-file
+              :id (:id pf)
+              :name "new file name"
+              :user (:id user)}
+        out  (th/try-on! (sm/handle data))]
+    ;; (th/print-result! out)
+    ;; TODO: check the result
+    (t/is (nil? (:error out)))
+    (t/is (nil? (:result out)))))
+
+(t/deftest mutation-delete-project-file
+  (let [user @(th/create-user db/pool 1)
+        proj @(th/create-project db/pool (:id user) 1)
+        pf   @(th/create-project-file db/pool (:id user) (:id proj) 1)
+        data {::sm/type :delete-project-file
+              :id (:id pf)
+              :user (:id user)}
+        out  (th/try-on! (sm/handle data))]
+    ;; (th/print-result! out)
+    (t/is (nil? (:error out)))
+    (t/is (nil? (:result out)))
+
+    (let [sql "select * from project_files
+                where project_id=$1 and deleted_at is null"
+          res @(db/query db/pool [sql (:id proj)])]
+      (t/is (empty? res)))))
+
+;; ;; TODO: add permisions related tests
diff --git a/backend/test/uxbox/tests/test_services_project_pages.clj b/backend/test/uxbox/tests/test_services_project_pages.clj
new file mode 100644
index 000000000..3133c7824
--- /dev/null
+++ b/backend/test/uxbox/tests/test_services_project_pages.clj
@@ -0,0 +1,84 @@
+(ns uxbox.tests.test-services-project-pages
+  (:require
+   [clojure.spec.alpha :as s]
+   [clojure.test :as t]
+   [promesa.core :as p]
+   [uxbox.db :as db]
+   [uxbox.http :as http]
+   [uxbox.services.mutations :as sm]
+   [uxbox.services.queries :as sq]
+   [uxbox.tests.helpers :as th]))
+
+(t/use-fixtures :once th/state-init)
+(t/use-fixtures :each th/database-reset)
+
+(t/deftest query-project-pages
+  (let [user @(th/create-user db/pool 1)
+        proj @(th/create-project db/pool (:id user) 1)
+        file @(th/create-project-file db/pool (:id user) (:id proj) 1)
+        page @(th/create-project-page db/pool (:id user) (:id file) 1)
+        data {::sq/type :project-pages
+              :file-id (:id file)
+              :user (:id user)}
+        out (th/try-on! (sq/handle data))]
+    ;; (th/print-result! out)
+    (t/is (nil? (:error out)))
+    (t/is (vector? (:result out)))
+    (t/is (= 1 (count (:result out))))
+    (t/is (= "page1" (get-in out [:result 0 :name])))
+    (t/is (:id file) (get-in out [:result 0 :file-id]))))
+
+(t/deftest mutation-create-project-page
+  (let [user @(th/create-user db/pool 1)
+        proj @(th/create-project db/pool (:id user) 1)
+        pf   @(th/create-project-file db/pool (:id user) (:id proj) 1)
+
+        data {::sm/type :create-project-page
+              :data {}
+              :metadata {}
+              :file-id (:id pf)
+              :ordering 1
+              :name "test page"
+              :user (:id user)}
+        out (th/try-on! (sm/handle data))]
+    ;; (th/print-result! out)
+    (t/is (nil? (:error out)))
+    (t/is (uuid? (get-in out [:result :id])))
+    (t/is (= (:user data) (get-in out [:result :user-id])))
+    (t/is (= (:name data) (get-in out [:result :name])))
+    (t/is (= (:data data) (get-in out [:result :data])))
+    (t/is (= (:metadata data) (get-in out [:result :metadata])))
+    (t/is (= 0 (get-in out [:result :version])))))
+
+(t/deftest mutation-update-project-page-data
+  (let [user @(th/create-user db/pool 1)
+        proj @(th/create-project db/pool (:id user) 1)
+        file @(th/create-project-file db/pool (:id user) (:id proj) 1)
+        page @(th/create-project-page db/pool (:id user) (:id file) 1)
+        data {::sm/type :update-project-page-data
+              :id (:id page)
+              :data {:shapes [1 2 3]}
+              :file-id (:id file)
+              :user (:id user)}
+        out (th/try-on! (sm/handle data))]
+
+    ;; (th/print-result! out)
+    ;; TODO: check history creation
+    ;; TODO: check correct page data update operation
+
+    (t/is (nil? (:error out)))
+    (t/is (= (:id data) (get-in out [:result :id])))
+    (t/is (= 1 (get-in out [:result :version])))))
+
+(t/deftest mutation-delete-project-page
+  (let [user @(th/create-user db/pool 1)
+        proj @(th/create-project db/pool (:id user) 1)
+        file @(th/create-project-file db/pool (:id user) (:id proj) 1)
+        page @(th/create-project-page db/pool (:id user) (:id file) 1)
+        data {::sm/type :delete-project-page
+              :id (:id page)
+              :user (:id user)}
+        out (th/try-on! (sm/handle data))]
+    ;; (th/print-result! out)
+    (t/is (nil? (:error out)))
+    (t/is (nil? (:result out)))))
diff --git a/backend/test/uxbox/tests/test_services_projects.clj b/backend/test/uxbox/tests/test_services_projects.clj
index eb5d4cc61..9d354fa76 100644
--- a/backend/test/uxbox/tests/test_services_projects.clj
+++ b/backend/test/uxbox/tests/test_services_projects.clj
@@ -11,17 +11,13 @@
 (t/use-fixtures :once th/state-init)
 (t/use-fixtures :each th/database-reset)
 
-;; TODO: migrate from try-on to try-on!
-
-(t/deftest query-project-list
+(t/deftest query-projects
   (let [user @(th/create-user db/pool 1)
         proj @(th/create-project db/pool (:id user) 1)
         data {::sq/type :projects
               :user (:id user)}
         out (th/try-on! (sq/handle data))]
-
     ;; (th/print-result! out)
-
     (t/is (nil? (:error out)))
     (t/is (= 1 (count (:result out))))
     (t/is (= (:id proj) (get-in out [:result 0 :id])))
@@ -32,11 +28,10 @@
         data {::sm/type :create-project
               :user (:id user)
               :name "test project"}
-        [err rsp] (th/try-on (sm/handle data))]
-    ;; (prn "RESPONSE:" err rsp)
-    (t/is (nil? err))
-    (t/is (= (:user data) (:user-id rsp)))
-    (t/is (= (:name data) (:name rsp)))))
+        out (th/try-on! (sm/handle data))]
+    ;; (th/print-result! out)
+    (t/is (nil? (:error out)))
+    (t/is (= (:name data) (get-in out [:result :name])))))
 
 (t/deftest mutation-update-project
   (let [user @(th/create-user db/pool 1)
@@ -45,12 +40,12 @@
               :id (:id proj)
               :name "test project mod"
               :user (:id user)}
-        [err rsp] (th/try-on (sm/handle data))]
-    ;; (prn "RESPONSE:" err rsp)
-    (t/is (nil? err))
-    (t/is (= (:id data) (:id rsp)))
-    (t/is (= (:user data) (:user-id rsp)))
-    (t/is (= (:name data) (:name rsp)))))
+        out  (th/try-on! (sm/handle data))]
+    ;; (th/print-result! out)
+    (t/is (nil? (:error out)))
+    (t/is (= (:id data) (get-in out [:result :id])))
+    (t/is (= (:user data) (get-in out [:result :user-id])))
+    (t/is (= (:name data) (get-in out [:result :name])))))
 
 (t/deftest mutation-delete-project
   (let [user @(th/create-user db/pool 1)
@@ -58,12 +53,13 @@
         data {::sm/type :delete-project
               :id (:id proj)
               :user (:id user)}
-        [err rsp] (th/try-on (sm/handle data))]
-    ;; (prn "RESPONSE:" err rsp)
-    (t/is (nil? err))
-    (t/is (nil? rsp))
+        out  (th/try-on! (sm/handle data))]
+    ;; (th/print-result! out)
+    (t/is (nil? (:error out)))
+    (t/is (nil? (:result out)))
 
-    (let [sql "SELECT * FROM projects
-                WHERE user_id=$1 AND deleted_at is null"
+    (let [sql "select * from projects where user_id=$1 and deleted_at is null"
           res @(db/query db/pool [sql (:id user)])]
       (t/is (empty? res)))))
+
+;; TODO: add permisions related tests
diff --git a/backend/test/uxbox/tests/test_services_user_storage.clj b/backend/test/uxbox/tests/test_services_user_attrs.clj
similarity index 62%
rename from backend/test/uxbox/tests/test_services_user_storage.clj
rename to backend/test/uxbox/tests/test_services_user_attrs.clj
index 8585f5e96..db9826d9e 100644
--- a/backend/test/uxbox/tests/test_services_user_storage.clj
+++ b/backend/test/uxbox/tests/test_services_user_attrs.clj
@@ -1,4 +1,4 @@
-(ns uxbox.tests.test-services-user-storage
+(ns uxbox.tests.test-services-user-attrs
   (:require
    [clojure.spec.alpha :as s]
    [clojure.test :as t]
@@ -12,16 +12,22 @@
 (t/use-fixtures :once th/state-init)
 (t/use-fixtures :each th/database-reset)
 
-(t/deftest test-user-storage
+(t/deftest test-user-attrs
   (let [{:keys [id] :as user} @(th/create-user db/pool 1)]
-    (let [out (th/try-on! (sq/handle {::sq/type :user-storage-entry
+    (let [out (th/try-on! (sq/handle {::sq/type :user-attr
                                       :key "foobar"
                                       :user id}))]
       (t/is (nil? (:result out)))
-      (t/is (map? (:error out)))
-      (t/is (= :not-found (get-in out [:error :type]))))
 
-    (let [out (th/try-on! (sm/handle {::sm/type :upsert-user-storage-entry
+      (let [error (:error out)]
+        (t/is (th/ex-info? error))
+        (t/is (th/ex-of-type? error :service-error)))
+
+      (let [error (ex-cause (:error out))]
+        (t/is (th/ex-info? error))
+        (t/is (th/ex-of-type? error :not-found))))
+
+    (let [out (th/try-on! (sm/handle {::sm/type :upsert-user-attr
                                       :user id
                                       :key "foobar"
                                       :val {:some #{:value}}}))]
@@ -29,7 +35,7 @@
       (t/is (nil? (:error out)))
       (t/is (nil? (:result out))))
 
-    (let [out (th/try-on! (sq/handle {::sq/type :user-storage-entry
+    (let [out (th/try-on! (sq/handle {::sq/type :user-attr
                                       :key "foobar"
                                       :user id}))]
       ;; (th/print-result! out)
@@ -37,18 +43,23 @@
       (t/is (= {:some #{:value}} (get-in out [:result :val])))
       (t/is (= "foobar" (get-in out [:result :key]))))
 
-    (let [out (th/try-on! (sm/handle {::sm/type :delete-user-storage-entry
+    (let [out (th/try-on! (sm/handle {::sm/type :delete-user-attr
                                       :user id
                                       :key "foobar"}))]
       ;; (th/print-result! out)
       (t/is (nil? (:error out)))
       (t/is (nil? (:result out))))
 
-    (let [out (th/try-on! (sq/handle {::sq/type :user-storage-entry
+    (let [out (th/try-on! (sq/handle {::sq/type :user-attr
                                       :key "foobar"
                                       :user id}))]
       ;; (th/print-result! out)
       (t/is (nil? (:result out)))
-      (t/is (map? (:error out)))
-      (t/is (= :not-found (get-in out [:error :type]))))))
+      (let [error (:error out)]
+        (t/is (th/ex-info? error))
+        (t/is (th/ex-of-type? error :service-error)))
+
+      (let [error (ex-cause (:error out))]
+        (t/is (th/ex-info? error))
+        (t/is (th/ex-of-type? error :not-found))))))
 
diff --git a/backend/test/uxbox/tests/test_users.clj b/backend/test/uxbox/tests/test_services_users.clj
similarity index 65%
rename from backend/test/uxbox/tests/test_users.clj
rename to backend/test/uxbox/tests/test_services_users.clj
index f761b32ed..ab64fb38d 100644
--- a/backend/test/uxbox/tests/test_users.clj
+++ b/backend/test/uxbox/tests/test_services_users.clj
@@ -1,11 +1,10 @@
-(ns uxbox.tests.test-users
+(ns uxbox.tests.test-services-users
   (:require
    [clojure.test :as t]
    [clojure.java.io :as io]
    [promesa.core :as p]
    [cuerdas.core :as str]
    [datoteka.core :as fs]
-   [vertx.core :as vc]
    [uxbox.db :as db]
    [uxbox.services.mutations :as sm]
    [uxbox.services.queries :as sq]
@@ -16,47 +15,48 @@
 
 (t/deftest test-query-profile
   (let [user @(th/create-user db/pool 1)
-        event {::sq/type :profile
-               :user (:id user)}
-        [err rsp] (th/try-on (sq/handle event))]
-    ;; (println "RESPONSE:" resp)))
-    (t/is (nil? err))
-    (t/is (= (:fullname rsp) "User 1"))
-    (t/is (= (:username rsp) "user1"))
-    (t/is (= (:metadata rsp) {}))
-    (t/is (= (:email rsp) "user1.test@uxbox.io"))
-    (t/is (not (contains? rsp :password)))))
+        data {::sq/type :profile
+              :user (:id user)}
+
+        out (th/try-on! (sq/handle data))]
+    ;; (th/print-result! out)
+    (t/is (nil? (:error out)))
+    (t/is (= "User 1" (get-in out [:result :fullname])))
+    (t/is (= "user1" (get-in out [:result :username])))
+    (t/is (= "user1.test@uxbox.io" (get-in out [:result :email])))
+    (t/is (not (contains? (:result out) :password)))))
 
 (t/deftest test-mutation-update-profile
-  (let [user  @(th/create-user db/pool 1)
-        event (assoc user
-                     ::sm/type :update-profile
-                     :fullname "Full Name"
-                     :username "user222"
-                     :metadata {:foo "bar"}
-                     :email "user222@uxbox.io")
-        [err data] (th/try-on (sm/handle event))]
-    ;; (println "RESPONSE:" err data)
-    (t/is (nil? err))
-    (t/is (= (:fullname data) "Full Name"))
-    (t/is (= (:username data) "user222"))
-    (t/is (= (:metadata data) {:foo "bar"}))
-    (t/is (= (:email data) "user222@uxbox.io"))
-    (t/is (not (contains? data :password)))))
+  (let [user @(th/create-user db/pool 1)
+        data (assoc user
+                    ::sm/type :update-profile
+                    :fullname "Full Name"
+                    :username "user222"
+                    :metadata {:foo "bar"}
+                    :email "user222@uxbox.io")
+        out (th/try-on! (sm/handle data))]
+    ;; (th/print-result! out)
+    (t/is (nil? (:error out)))
+    (t/is (= (:fullname data) (get-in out [:result :fullname])))
+    (t/is (= (:username data) (get-in out [:result :username])))
+    (t/is (= (:email data) (get-in out [:result :email])))
+    (t/is (= (:metadata data) (get-in out [:result :metadata])))
+    (t/is (not (contains? (:result out) :password)))))
 
 (t/deftest test-mutation-update-profile-photo
   (let [user  @(th/create-user db/pool 1)
-        event {::sm/type :update-profile-photo
-               :user (:id user)
-               :file {:name "sample.jpg"
-                      :path (fs/path "test/uxbox/tests/_files/sample.jpg")
-                      :size 123123
-                      :mtype "image/jpeg"}}
-        [err rsp] (th/try-on (sm/handle event))]
-    ;; (prn "RESPONSE:" [err rsp])
-    (t/is (nil? err))
-    (t/is (= (:id user) (:id rsp)))
-    (t/is (str/starts-with? (:photo rsp) "http"))))
+        data {::sm/type :update-profile-photo
+              :user (:id user)
+              :file {:name "sample.jpg"
+                     :path (fs/path "test/uxbox/tests/_files/sample.jpg")
+                     :size 123123
+                     :mtype "image/jpeg"}}
+
+        out (th/try-on! (sm/handle data))]
+    ;; (th/print-result! out)
+    (t/is (nil? (:error out)))
+    (t/is (= (:id user) (get-in out [:result :id])))
+    (t/is (str/starts-with? (get-in out [:result :photo]) "http"))))
 
 ;; (t/deftest test-mutation-register-profile
 ;;   (let[data {:fullname "Full Name"
diff --git a/backend/test/uxbox/tests/test_util_svg.clj b/backend/test/uxbox/tests/test_util_svg.clj
index a58c2c6f6..75270bb6b 100644
--- a/backend/test/uxbox/tests/test_util_svg.clj
+++ b/backend/test/uxbox/tests/test_util_svg.clj
@@ -34,19 +34,20 @@
 
 (t/deftest parse-invalid-svg-1
   (let [image (io/resource "uxbox/tests/_files/sample.jpg")
-        result (th/try! (svg/parse image))]
-    (t/is (map? (:error result)))
-    (t/is (= (get-in result [:error :code])
-             ::svg/invalid-input))))
+        out (th/try! (svg/parse image))]
+
+    (let [error (:error out)]
+      (t/is (th/ex-info? error))
+      (t/is (th/ex-of-code? error ::svg/invalid-input)))))
 
 (t/deftest parse-invalid-svg-2
-  (let [result (th/try! (svg/parse-string ""))]
-    (t/is (map? (:error result)))
-    (t/is (= (get-in result [:error :code])
-             ::svg/invalid-input))))
+  (let [out (th/try! (svg/parse-string ""))]
+    (let [error (:error out)]
+      (t/is (th/ex-info? error))
+      (t/is (th/ex-of-code? error ::svg/invalid-input)))))
 
 (t/deftest parse-invalid-svg-3
-  (let [result (th/try! (svg/parse-string "<svg></svg>"))]
-    (t/is (map? (:error result)))
-    (t/is (= (get-in result [:error :code])
-             ::svg/invalid-result))))
+  (let [out (th/try! (svg/parse-string "<svg></svg>"))]
+    (let [error (:error out)]
+      (t/is (th/ex-info? error))
+      (t/is (th/ex-of-code? error ::svg/invalid-result)))))