From a782ed061835c3c91003075d95037e7caa7db41b Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Tue, 3 Dec 2024 13:30:35 +0100
Subject: [PATCH 01/52] fix(plugins): improve types of plugins returned from
 the container

---
 packages/plugins/src/PluginsContainer.ts | 22 +++++++++++++---------
 1 file changed, 13 insertions(+), 9 deletions(-)

diff --git a/packages/plugins/src/PluginsContainer.ts b/packages/plugins/src/PluginsContainer.ts
index 48ecdd719ae..7407178beb8 100644
--- a/packages/plugins/src/PluginsContainer.ts
+++ b/packages/plugins/src/PluginsContainer.ts
@@ -1,6 +1,8 @@
 import { Plugin, PluginCollection } from "./types";
 import uniqid from "uniqid";
 
+export type WithName<T extends Plugin> = T & { name: string };
+
 const isOptionsObject = (item?: any) => item && !Array.isArray(item) && !item.type && !item.name;
 const normalizeArgs = (args: any[]): [Plugin[], any] => {
     let options = {};
@@ -53,32 +55,32 @@ const assign = (
 
 export class PluginsContainer {
     private plugins: Record<string, Plugin> = {};
-    private _byTypeCache: Record<string, Plugin[]> = {};
+    private _byTypeCache: Record<string, WithName<Plugin>[]> = {};
 
     constructor(...args: PluginCollection) {
         this.register(...args);
     }
 
-    public byName<T extends Plugin>(name: T["name"]): T | null {
+    public byName<T extends Plugin>(name: T["name"]) {
         if (!name) {
             return null;
         }
         /**
          * We can safely cast name as string, we know it is so.
          */
-        return this.plugins[name as string] as T;
+        return this.plugins[name as string] as WithName<T>;
     }
 
-    public byType<T extends Plugin>(type: T["type"]): T[] {
+    public byType<T extends Plugin>(type: T["type"]) {
         if (this._byTypeCache[type]) {
-            return Array.from(this._byTypeCache[type]) as T[];
+            return Array.from(this._byTypeCache[type]) as WithName<T>[];
         }
         const plugins = this.findByType<T>(type);
         this._byTypeCache[type] = plugins;
         return Array.from(plugins);
     }
 
-    public atLeastOneByType<T extends Plugin>(type: T["type"]): T[] {
+    public atLeastOneByType<T extends Plugin>(type: T["type"]) {
         const list = this.byType<T>(type);
         if (list.length === 0) {
             throw new Error(`There are no plugins by type "${type}".`);
@@ -86,7 +88,7 @@ export class PluginsContainer {
         return list;
     }
 
-    public oneByType<T extends Plugin>(type: T["type"]): T {
+    public oneByType<T extends Plugin>(type: T["type"]) {
         const list = this.atLeastOneByType<T>(type);
         if (list.length > 1) {
             throw new Error(
@@ -125,7 +127,9 @@ export class PluginsContainer {
         delete this.plugins[name];
     }
 
-    private findByType<T extends Plugin>(type: T["type"]): T[] {
-        return Object.values(this.plugins).filter((pl): pl is T => pl.type === type) as T[];
+    private findByType<T extends Plugin>(type: T["type"]) {
+        return Object.values(this.plugins).filter(
+            (pl): pl is T => pl.type === type
+        ) as WithName<T>[];
     }
 }

From 10cd1e567e00b843beb9580df91fed22bfd9f446 Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Tue, 3 Dec 2024 13:31:31 +0100
Subject: [PATCH 02/52] fix(app-page-builder): create plugin loader components

---
 .../admin/components/EditorPluginsLoader.tsx  | 138 ------------------
 .../PluginLoaders/EditorPluginsLoader.tsx     |   8 +
 .../PluginLoaders/RenderPluginsLoader.tsx     |   8 +
 .../PluginLoaders/createPluginsLoader.tsx     |  65 +++++++++
 .../src/admin/plugins/routes.tsx              | 128 +++++++---------
 packages/app-page-builder/src/types.ts        |   4 +-
 6 files changed, 139 insertions(+), 212 deletions(-)
 delete mode 100644 packages/app-page-builder/src/admin/components/EditorPluginsLoader.tsx
 create mode 100644 packages/app-page-builder/src/admin/components/PluginLoaders/EditorPluginsLoader.tsx
 create mode 100644 packages/app-page-builder/src/admin/components/PluginLoaders/RenderPluginsLoader.tsx
 create mode 100644 packages/app-page-builder/src/admin/components/PluginLoaders/createPluginsLoader.tsx

diff --git a/packages/app-page-builder/src/admin/components/EditorPluginsLoader.tsx b/packages/app-page-builder/src/admin/components/EditorPluginsLoader.tsx
deleted file mode 100644
index 0bb6d03f2b0..00000000000
--- a/packages/app-page-builder/src/admin/components/EditorPluginsLoader.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-import React, { useEffect, useReducer } from "react";
-import * as History from "history";
-import { plugins } from "@webiny/plugins";
-import { CircularProgress } from "@webiny/ui/Progress";
-import { PbPluginsLoader } from "~/types";
-
-const globalState: State = {
-    render: false,
-    editor: false
-};
-
-// Since these plugins are loaded asynchronously, and some overrides might've been registered
-// already by the developer (e.g. in the main App.tsx file), we only register new plugins.
-// In other words, if the plugin with a particular name already exists, we skip its registration.
-
-interface State {
-    render?: boolean;
-    editor?: boolean;
-}
-
-interface EditorPluginsLoaderProps {
-    location: History.Location;
-    children: React.ReactNode;
-}
-
-export const EditorPluginsLoader = ({ children, location }: EditorPluginsLoaderProps) => {
-    const [loaded, setLoaded] = useReducer(
-        (state: State, newState: Partial<State>) => ({ ...state, ...newState }),
-        globalState
-    );
-
-    const isEditorRoute = [
-        "/page-builder/editor",
-        "/page-builder/block-editor",
-        "/page-builder/template-editor"
-    ].some(path => location.pathname.startsWith(path));
-
-    const loadPlugins = async () => {
-        const pbPlugins = plugins.byType<PbPluginsLoader>("pb-plugins-loader");
-        // load all editor admin plugins
-        const loadEditorPlugins = async () =>
-            await Promise.all(
-                pbPlugins
-                    .map(plugin => plugin.loadEditorPlugins && plugin.loadEditorPlugins())
-                    .filter(Boolean)
-            );
-        // load all editor render plugins
-        const loadRenderPlugins = async () =>
-            await Promise.all(
-                pbPlugins
-                    .map(plugin => plugin.loadRenderPlugins && plugin.loadRenderPlugins())
-                    .filter(Boolean)
-            );
-
-        // If we are on pages list route, import plugins required to render the page content.
-        if (location.pathname.startsWith("/page-builder/pages") && !loaded.render) {
-            const renderPlugins = await loadRenderPlugins();
-
-            // "skipExisting" will ensure existing plugins (with the same name) are not overridden.
-            plugins.register(renderPlugins, { skipExisting: true });
-
-            globalState.render = true;
-            setLoaded({ render: true });
-        }
-
-        // If we are on pages list route, import plugins required to render the page content.
-        if (location.pathname.startsWith("/page-builder/page-blocks") && !loaded.render) {
-            const renderPlugins = await loadRenderPlugins();
-
-            // "skipExisting" will ensure existing plugins (with the same name) are not overridden.
-            plugins.register(renderPlugins, { skipExisting: true });
-
-            globalState.render = true;
-            setLoaded({ render: true });
-        }
-
-        // If we are on page templates list route, import plugins required to render the template content.
-        if (location.pathname.startsWith("/page-builder/page-templates") && !loaded.render) {
-            const renderPlugins = await loadRenderPlugins();
-
-            // "skipExisting" will ensure existing plugins (with the same name) are not overridden.
-            plugins.register(renderPlugins, { skipExisting: true });
-
-            globalState.render = true;
-            setLoaded({ render: true });
-        }
-
-        // If we are on the Editor route, import plugins required to render both editor and preview.
-        if (isEditorRoute && !loaded.editor) {
-            const renderPlugins = !loaded.render ? await loadRenderPlugins() : [];
-            const editorAdminPlugins = await loadEditorPlugins();
-            // merge both editor admin and render plugins
-            const editorRenderPlugins = [...editorAdminPlugins, ...renderPlugins].filter(Boolean);
-
-            // "skipExisting" will ensure existing plugins (with the same name) are not overridden.
-            plugins.register(editorRenderPlugins, { skipExisting: true });
-
-            globalState.editor = true;
-            globalState.render = true;
-
-            setLoaded({ editor: true, render: true });
-        }
-    };
-
-    useEffect(() => {
-        loadPlugins();
-    }, [location.pathname]);
-
-    /**
-     * This condition is for the list of pages.
-     * Page can be selected at this point.
-     */
-    if (location.pathname.startsWith("/page-builder/pages") && loaded.render) {
-        return children as unknown as React.ReactElement;
-    }
-    /**
-     * This condition is for the list of page blocks.
-     * Blocks can be selected at this point.
-     */
-    if (location.pathname.startsWith("/page-builder/page-blocks") && loaded.render) {
-        return children as unknown as React.ReactElement;
-    }
-    /**
-     * This condition is for the list of page templates.
-     * Page template can be selected at this point.
-     */
-    if (location.pathname.startsWith("/page-builder/page-templates") && loaded.render) {
-        return children as unknown as React.ReactElement;
-    }
-    /**
-     * This condition is for editing of the selected page/template.
-     */
-    if (isEditorRoute && loaded.editor) {
-        return children as unknown as React.ReactElement;
-    }
-
-    return <CircularProgress />;
-};
diff --git a/packages/app-page-builder/src/admin/components/PluginLoaders/EditorPluginsLoader.tsx b/packages/app-page-builder/src/admin/components/PluginLoaders/EditorPluginsLoader.tsx
new file mode 100644
index 00000000000..9a469d77e63
--- /dev/null
+++ b/packages/app-page-builder/src/admin/components/PluginLoaders/EditorPluginsLoader.tsx
@@ -0,0 +1,8 @@
+import { createPluginsLoader } from "~/admin/components/PluginLoaders/createPluginsLoader";
+
+export const EditorPluginsLoader = createPluginsLoader({
+    type: "pb-editor-page-element",
+    factory: plugin => {
+        return plugin.loadEditorPlugins ? plugin.loadEditorPlugins() : undefined;
+    }
+});
diff --git a/packages/app-page-builder/src/admin/components/PluginLoaders/RenderPluginsLoader.tsx b/packages/app-page-builder/src/admin/components/PluginLoaders/RenderPluginsLoader.tsx
new file mode 100644
index 00000000000..ce354e95df0
--- /dev/null
+++ b/packages/app-page-builder/src/admin/components/PluginLoaders/RenderPluginsLoader.tsx
@@ -0,0 +1,8 @@
+import { createPluginsLoader } from "~/admin/components/PluginLoaders/createPluginsLoader";
+
+export const RenderPluginsLoader = createPluginsLoader({
+    type: "pb-render-page-element",
+    factory: plugin => {
+        return plugin.loadRenderPlugins ? plugin.loadRenderPlugins() : undefined;
+    }
+});
diff --git a/packages/app-page-builder/src/admin/components/PluginLoaders/createPluginsLoader.tsx b/packages/app-page-builder/src/admin/components/PluginLoaders/createPluginsLoader.tsx
new file mode 100644
index 00000000000..52c6bdb3822
--- /dev/null
+++ b/packages/app-page-builder/src/admin/components/PluginLoaders/createPluginsLoader.tsx
@@ -0,0 +1,65 @@
+import React, { useEffect, useState } from "react";
+import type { Plugin } from "@webiny/plugins/types";
+import { GenericRecord } from "@webiny/app/types";
+import { plugins } from "@webiny/plugins";
+import { CircularProgress } from "@webiny/ui/Progress";
+import type { PbEditorPageElementPlugin, PbPluginsLoader, PbRenderElementPlugin } from "~/types";
+
+export interface CreatePluginsLoaderParams {
+    // Plugin type
+    type: PbRenderElementPlugin["type"] | PbEditorPageElementPlugin["type"];
+    // Plugin factory
+    factory: (plugin: PbPluginsLoader) => Promise<Plugin[]> | undefined;
+}
+
+const globalCache: GenericRecord<string, boolean> = {};
+
+export interface PluginsLoaderProps {
+    children: React.ReactNode;
+}
+
+export const createPluginsLoader = ({ type, factory }: CreatePluginsLoaderParams) => {
+    const PluginsLoader = ({ children }: PluginsLoaderProps) => {
+        const [loaded, setLoaded] = useState(false);
+
+        const loadPlugins = async () => {
+            const pluginsLoaders = plugins.byType<PbPluginsLoader>("pb-plugins-loader");
+
+            const lazyLoadedPlugins = await Promise.all(
+                pluginsLoaders.map(plugin => factory(plugin)).filter(Boolean)
+            );
+
+            // Here comes an awkward hack: there's a chance that a user registered some custom plugins through React,
+            // and they're already in the registry. But we want to make sure that user plugins are applied _after_ the lazy-loaded
+            // plugins, loaded via the `pb-plugins-loader`. To achieve that, we unregister existing plugins, and register them
+            // _after_ the lazy-loaded ones.
+
+            const existingPlugins = plugins.byType(type);
+
+            existingPlugins.forEach(plugin => {
+                plugins.unregister(plugin.name);
+            });
+
+            // Register lazy-loaded plugins first.
+            plugins.register(lazyLoadedPlugins);
+            plugins.register(existingPlugins);
+        };
+
+        useEffect(() => {
+            if (!globalCache[type]) {
+                loadPlugins().then(() => {
+                    globalCache[type] = true;
+                    setLoaded(true);
+                });
+            } else {
+                setLoaded(true);
+            }
+        }, []);
+
+        return loaded ? <>{children}</> : <CircularProgress />;
+    };
+
+    PluginsLoader.displayName = `PluginsLoader<${type}>`;
+
+    return PluginsLoader;
+};
diff --git a/packages/app-page-builder/src/admin/plugins/routes.tsx b/packages/app-page-builder/src/admin/plugins/routes.tsx
index ab702c51081..8cd18ad6eb2 100644
--- a/packages/app-page-builder/src/admin/plugins/routes.tsx
+++ b/packages/app-page-builder/src/admin/plugins/routes.tsx
@@ -5,7 +5,6 @@ import { AdminLayout } from "@webiny/app-admin/components/AdminLayout";
 import { SecureRoute } from "@webiny/app-security/components";
 import { RoutePlugin } from "@webiny/app/types";
 import { CompositionScope } from "@webiny/react-composition";
-import { EditorPluginsLoader } from "../components/EditorPluginsLoader";
 
 import Categories from "../views/Categories/Categories";
 import Menus from "../views/Menus/Menus";
@@ -17,6 +16,8 @@ import PageTemplates from "~/admin/views/PageTemplates/PageTemplates";
 import { PageEditor } from "~/pageEditor/Editor";
 import { BlockEditor } from "~/blockEditor/Editor";
 import { TemplateEditor } from "~/templateEditor/Editor";
+import { RenderPluginsLoader } from "~/admin/components/PluginLoaders/RenderPluginsLoader";
+import { EditorPluginsLoader } from "~/admin/components/PluginLoaders/EditorPluginsLoader";
 
 const ROLE_PB_CATEGORY = "pb.category";
 const ROLE_PB_MENUS = "pb.menu";
@@ -30,16 +31,15 @@ const plugins: RoutePlugin[] = [
         type: "route",
         route: (
             <Route
-                exact
                 path="/page-builder/categories"
-                render={() => (
+                element={
                     <SecureRoute permission={ROLE_PB_CATEGORY}>
                         <AdminLayout>
                             <Helmet title={"Page Builder - Categories"} />
                             <Categories />
                         </AdminLayout>
                     </SecureRoute>
-                )}
+                }
             />
         )
     },
@@ -48,16 +48,15 @@ const plugins: RoutePlugin[] = [
         type: "route",
         route: (
             <Route
-                exact
                 path="/page-builder/menus"
-                render={() => (
+                element={
                     <SecureRoute permission={ROLE_PB_MENUS}>
                         <AdminLayout>
                             <Helmet title={"Page Builder - Menus"} />
                             <Menus />
                         </AdminLayout>
                     </SecureRoute>
-                )}
+                }
             />
         )
     },
@@ -66,20 +65,19 @@ const plugins: RoutePlugin[] = [
         type: "route",
         route: (
             <Route
-                exact
                 path="/page-builder/pages"
-                render={({ location }) => (
+                element={
                     <SecureRoute permission={ROLE_PB_PAGES}>
-                        <EditorPluginsLoader location={location}>
+                        <RenderPluginsLoader>
                             <AdminLayout>
                                 <Helmet title={"Page Builder - Pages"} />
                                 <CompositionScope name={"pb.page"}>
                                     <Pages />
                                 </CompositionScope>
                             </AdminLayout>
-                        </EditorPluginsLoader>
+                        </RenderPluginsLoader>
                     </SecureRoute>
-                )}
+                }
             />
         )
     },
@@ -88,20 +86,17 @@ const plugins: RoutePlugin[] = [
         type: "route",
         route: (
             <Route
-                exact
                 path="/page-builder/editor/:id"
-                render={({ location }) => {
-                    return (
-                        <SecureRoute permission={ROLE_PB_PAGES}>
-                            <EditorPluginsLoader location={location}>
-                                <Helmet title={"Page Builder - Edit page"} />
-                                <CompositionScope name={"pb.pageEditor"}>
-                                    <PageEditor />
-                                </CompositionScope>
-                            </EditorPluginsLoader>
-                        </SecureRoute>
-                    );
-                }}
+                element={
+                    <SecureRoute permission={ROLE_PB_PAGES}>
+                        <EditorPluginsLoader>
+                            <Helmet title={"Page Builder - Edit page"} />
+                            <CompositionScope name={"pb.pageEditor"}>
+                                <PageEditor />
+                            </CompositionScope>
+                        </EditorPluginsLoader>
+                    </SecureRoute>
+                }
             />
         )
     },
@@ -110,20 +105,17 @@ const plugins: RoutePlugin[] = [
         type: "route",
         route: (
             <Route
-                exact
                 path="/page-builder/page-templates"
-                render={({ location }) => {
-                    return (
-                        <SecureRoute permission={ROLE_PB_TEMPLATE}>
-                            <EditorPluginsLoader location={location}>
-                                <AdminLayout>
-                                    <Helmet title={"Page Builder - Page Templates"} />
-                                    <PageTemplates />
-                                </AdminLayout>
-                            </EditorPluginsLoader>
-                        </SecureRoute>
-                    );
-                }}
+                element={
+                    <SecureRoute permission={ROLE_PB_TEMPLATE}>
+                        <RenderPluginsLoader>
+                            <AdminLayout>
+                                <Helmet title={"Page Builder - Page Templates"} />
+                                <PageTemplates />
+                            </AdminLayout>
+                        </RenderPluginsLoader>
+                    </SecureRoute>
+                }
             />
         )
     },
@@ -132,20 +124,17 @@ const plugins: RoutePlugin[] = [
         type: "route",
         route: (
             <Route
-                exact
                 path="/page-builder/template-editor/:id"
-                render={({ location }) => {
-                    return (
-                        <SecureRoute permission={ROLE_PB_TEMPLATE}>
-                            <EditorPluginsLoader location={location}>
-                                <Helmet title={"Page Builder - Edit template"} />
-                                <CompositionScope name={"pb.templateEditor"}>
-                                    <TemplateEditor />
-                                </CompositionScope>
-                            </EditorPluginsLoader>
-                        </SecureRoute>
-                    );
-                }}
+                element={
+                    <SecureRoute permission={ROLE_PB_TEMPLATE}>
+                        <EditorPluginsLoader>
+                            <Helmet title={"Page Builder - Edit template"} />
+                            <CompositionScope name={"pb.templateEditor"}>
+                                <TemplateEditor />
+                            </CompositionScope>
+                        </EditorPluginsLoader>
+                    </SecureRoute>
+                }
             />
         )
     },
@@ -154,16 +143,15 @@ const plugins: RoutePlugin[] = [
         type: "route",
         route: (
             <Route
-                exact
                 path="/page-builder/block-categories"
-                render={() => (
+                element={
                     <SecureRoute permission={ROLE_PB_BLOCK}>
                         <AdminLayout>
                             <Helmet title={"Page Builder - Block Categories"} />
                             <BlockCategories />
                         </AdminLayout>
                     </SecureRoute>
-                )}
+                }
             />
         )
     },
@@ -172,18 +160,17 @@ const plugins: RoutePlugin[] = [
         type: "route",
         route: (
             <Route
-                exact
                 path="/page-builder/page-blocks"
-                render={({ location }) => (
+                element={
                     <SecureRoute permission={ROLE_PB_BLOCK}>
-                        <EditorPluginsLoader location={location}>
+                        <RenderPluginsLoader>
                             <AdminLayout>
                                 <Helmet title={"Page Builder - Blocks"} />
                                 <PageBlocks />
                             </AdminLayout>
-                        </EditorPluginsLoader>
+                        </RenderPluginsLoader>
                     </SecureRoute>
-                )}
+                }
             />
         )
     },
@@ -192,20 +179,17 @@ const plugins: RoutePlugin[] = [
         type: "route",
         route: (
             <Route
-                exact
                 path="/page-builder/block-editor/:id"
-                render={({ location }) => {
-                    return (
-                        <SecureRoute permission={ROLE_PB_PAGES}>
-                            <EditorPluginsLoader location={location}>
-                                <Helmet title={"Page Builder - Edit block"} />
-                                <CompositionScope name={"pb.blockEditor"}>
-                                    <BlockEditor />
-                                </CompositionScope>
-                            </EditorPluginsLoader>
-                        </SecureRoute>
-                    );
-                }}
+                element={
+                    <SecureRoute permission={ROLE_PB_PAGES}>
+                        <EditorPluginsLoader>
+                            <Helmet title={"Page Builder - Edit block"} />
+                            <CompositionScope name={"pb.blockEditor"}>
+                                <BlockEditor />
+                            </CompositionScope>
+                        </EditorPluginsLoader>
+                    </SecureRoute>
+                }
             />
         )
     }
diff --git a/packages/app-page-builder/src/types.ts b/packages/app-page-builder/src/types.ts
index 80bf940ad76..f16448da794 100644
--- a/packages/app-page-builder/src/types.ts
+++ b/packages/app-page-builder/src/types.ts
@@ -302,8 +302,8 @@ export interface PbTheme {
 }
 
 export type PbPluginsLoader = Plugin & {
-    loadEditorPlugins?: () => Promise<any>;
-    loadRenderPlugins?: () => Promise<any>;
+    loadEditorPlugins?: () => Promise<Plugin[]> | undefined;
+    loadRenderPlugins?: () => Promise<Plugin[]> | undefined;
 };
 
 export type PbThemePlugin = Plugin & {

From 445bb48de3b07fee5bf3c15e597d617e5841984d Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Tue, 3 Dec 2024 13:51:11 +0100
Subject: [PATCH 03/52] fix(app-page-builder): export loader components

---
 packages/app-page-builder/src/admin/index.ts | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/packages/app-page-builder/src/admin/index.ts b/packages/app-page-builder/src/admin/index.ts
index 03cfdd14766..e5467bb3793 100644
--- a/packages/app-page-builder/src/admin/index.ts
+++ b/packages/app-page-builder/src/admin/index.ts
@@ -5,3 +5,6 @@ export * from "./hooks/usePageBuilderSettings";
 export * from "./hooks/useConfigureWebsiteUrl";
 export * from "./hooks/useSiteStatus";
 export * from "./hooks/useAdminPageBuilder";
+
+export * from "./components/PluginLoaders/EditorPluginsLoader";
+export * from "./components/PluginLoaders/RenderPluginsLoader";

From bd1354d8ee670ec47eebfcbcfd8cf09c24b8efc0 Mon Sep 17 00:00:00 2001
From: Adrian Smijulj <adrian1358@gmail.com>
Date: Thu, 5 Dec 2024 05:44:32 +0100
Subject: [PATCH 04/52] fix: introduce `useLoader` hook (#4424)

---
 .github/workflows/pullRequests.yml            | 36 +++++-----
 .github/workflows/wac/pullRequests.wac.ts     |  5 +-
 .../extractPeLoaderDataFromHtml.test.ts       | 45 ++++++++++++
 .../api-prerendering-service/package.json     |  2 +
 .../src/render/extractPeLoaderDataFromHtml.ts | 69 +++++++++++++++++++
 .../src/render/renderUrl.ts                   | 31 +++++----
 .../src/render/types.ts                       | 16 ++++-
 .../src/contexts/PageElements.tsx             | 15 ++--
 .../src/hooks/useLoader.ts                    | 61 ++++++++++++++++
 .../src/hooks/useLoader/ILoaderCache.ts       |  6 ++
 .../src/hooks/useLoader/NullLoaderCache.ts    | 19 +++++
 .../src/hooks/useLoader/createObjectHash.ts   | 17 +++++
 .../app-page-builder-elements/src/index.ts    |  1 +
 .../app-page-builder-elements/src/types.ts    |  2 +
 packages/app-page-builder/src/PageBuilder.tsx |  9 ++-
 .../ResponsiveElementsProvider.tsx            |  9 ++-
 .../PageBuilder/PageBuilderContext.tsx        |  6 +-
 .../PageBuilder/PageElementsProvider.tsx      |  9 ++-
 .../contexts/EditorPageElementsProvider.tsx   |  6 ++
 .../elementSettings/save/SaveDialog.tsx       |  9 ++-
 packages/app-website/src/LinkPreload.tsx      | 33 ++++++---
 packages/app-website/src/Website.tsx          |  9 ++-
 .../src/utils/WebsiteLoaderCache.ts           | 37 ++++++++++
 .../WebsiteLoaderCache/PeLoaderHtmlCache.ts   | 26 +++++++
 .../ddbPutItemConditionalCheckFailed.js       | 12 ++++
 .../gracefulPulumiErrorHandlers/index.js      |  3 +-
 .../src/promptQuestions.ts                    |  5 +-
 yarn.lock                                     |  9 +++
 28 files changed, 446 insertions(+), 61 deletions(-)
 create mode 100644 packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts
 create mode 100644 packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts
 create mode 100644 packages/app-page-builder-elements/src/hooks/useLoader.ts
 create mode 100644 packages/app-page-builder-elements/src/hooks/useLoader/ILoaderCache.ts
 create mode 100644 packages/app-page-builder-elements/src/hooks/useLoader/NullLoaderCache.ts
 create mode 100644 packages/app-page-builder-elements/src/hooks/useLoader/createObjectHash.ts
 create mode 100644 packages/app-website/src/utils/WebsiteLoaderCache.ts
 create mode 100644 packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts
 create mode 100644 packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/ddbPutItemConditionalCheckFailed.js

diff --git a/.github/workflows/pullRequests.yml b/.github/workflows/pullRequests.yml
index adb49a400f8..07a21d893db 100644
--- a/.github/workflows/pullRequests.yml
+++ b/.github/workflows/pullRequests.yml
@@ -3,7 +3,7 @@
 # and run "github-actions-wac build" (or "ghawac build") to regenerate this file.
 # For more information, run "github-actions-wac --help".
 name: Pull Requests
-'on': pull_request
+"on": pull_request
 concurrency:
   group: pr-${{ github.event.pull_request.number }}
   cancel-in-progress: true
@@ -19,7 +19,7 @@ jobs:
       - uses: webiny/action-conventional-commits@v1.3.0
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: '--max_old_space_size=4096'
+      NODE_OPTIONS: "--max_old_space_size=4096"
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   validateCommitsDev:
     name: Validate commit messages (dev branch, 'feat' commits not allowed)
@@ -34,7 +34,7 @@ jobs:
           allowed-commit-types: fix,docs,style,refactor,test,build,perf,ci,chore,revert,merge,wip
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: '--max_old_space_size=4096'
+      NODE_OPTIONS: "--max_old_space_size=4096"
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   constants:
     name: Create constants
@@ -87,12 +87,14 @@ jobs:
           $GITHUB_OUTPUT
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: '--max_old_space_size=4096'
+      NODE_OPTIONS: "--max_old_space_size=4096"
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   assignMilestone:
     name: Assign milestone
     needs: constants
-    if: needs.constants.outputs.is-fork-pr != 'true'
+    if: >-
+      needs.constants.outputs.is-fork-pr != 'true' &&
+      github.event.pull_request.milestone == null
     steps:
       - uses: actions/setup-node@v4
         with:
@@ -115,7 +117,7 @@ jobs:
           milestone: ${{ steps.get-milestone-to-assign.outputs.milestone }}
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: '--max_old_space_size=4096'
+      NODE_OPTIONS: "--max_old_space_size=4096"
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   build:
     name: Build
@@ -147,7 +149,7 @@ jobs:
           path: ${{ github.base_ref }}/.webiny/cached-packages
           key: ${{ needs.constants.outputs.run-cache-key }}
     env:
-      NODE_OPTIONS: '--max_old_space_size=4096'
+      NODE_OPTIONS: "--max_old_space_size=4096"
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   staticCodeAnalysis:
     needs:
@@ -185,7 +187,7 @@ jobs:
         working-directory: ${{ github.base_ref }}
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: '--max_old_space_size=4096'
+      NODE_OPTIONS: "--max_old_space_size=4096"
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   staticCodeAnalysisTs:
     name: Static code analysis (TypeScript)
@@ -211,7 +213,7 @@ jobs:
         run: yarn cy:ts
         working-directory: ${{ github.base_ref }}
     env:
-      NODE_OPTIONS: '--max_old_space_size=4096'
+      NODE_OPTIONS: "--max_old_space_size=4096"
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   jestTestsNoStorageConstants:
     needs:
@@ -239,7 +241,7 @@ jobs:
           echo '${{
           steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}'
     env:
-      NODE_OPTIONS: '--max_old_space_size=4096'
+      NODE_OPTIONS: "--max_old_space_size=4096"
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   jestTestsNoStorageRun:
     needs:
@@ -260,7 +262,7 @@ jobs:
           }}
     runs-on: ${{ matrix.os }}
     env:
-      NODE_OPTIONS: '--max_old_space_size=4096'
+      NODE_OPTIONS: "--max_old_space_size=4096"
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
       AWS_REGION: eu-central-1
     if: needs.jestTestsNoStorageConstants.outputs.packages-to-jest-test != '[]'
@@ -357,7 +359,7 @@ jobs:
           echo '${{
           steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}'
     env:
-      NODE_OPTIONS: '--max_old_space_size=4096'
+      NODE_OPTIONS: "--max_old_space_size=4096"
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   jestTestsddbRun:
     needs:
@@ -377,7 +379,7 @@ jobs:
           fromJson(needs.jestTestsddbConstants.outputs.packages-to-jest-test) }}
     runs-on: ${{ matrix.os }}
     env:
-      NODE_OPTIONS: '--max_old_space_size=4096'
+      NODE_OPTIONS: "--max_old_space_size=4096"
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
       AWS_REGION: eu-central-1
     if: needs.jestTestsddbConstants.outputs.packages-to-jest-test != '[]'
@@ -474,7 +476,7 @@ jobs:
           echo '${{
           steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}'
     env:
-      NODE_OPTIONS: '--max_old_space_size=4096'
+      NODE_OPTIONS: "--max_old_space_size=4096"
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   jestTestsddb-esRun:
     needs:
@@ -495,7 +497,7 @@ jobs:
           }}
     runs-on: ${{ matrix.os }}
     env:
-      NODE_OPTIONS: '--max_old_space_size=4096'
+      NODE_OPTIONS: "--max_old_space_size=4096"
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
       AWS_REGION: eu-central-1
       AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }}
@@ -604,7 +606,7 @@ jobs:
           echo '${{
           steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}'
     env:
-      NODE_OPTIONS: '--max_old_space_size=4096'
+      NODE_OPTIONS: "--max_old_space_size=4096"
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   jestTestsddb-osRun:
     needs:
@@ -625,7 +627,7 @@ jobs:
           }}
     runs-on: ${{ matrix.os }}
     env:
-      NODE_OPTIONS: '--max_old_space_size=4096'
+      NODE_OPTIONS: "--max_old_space_size=4096"
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
       AWS_REGION: eu-central-1
       AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_OPEN_SEARCH_DOMAIN_NAME }}
diff --git a/.github/workflows/wac/pullRequests.wac.ts b/.github/workflows/wac/pullRequests.wac.ts
index 723dcc6e574..60b4a28f061 100644
--- a/.github/workflows/wac/pullRequests.wac.ts
+++ b/.github/workflows/wac/pullRequests.wac.ts
@@ -210,7 +210,10 @@ export const pullRequests = createWorkflow({
         assignMilestone: createJob({
             name: "Assign milestone",
             needs: "constants",
-            if: "needs.constants.outputs.is-fork-pr != 'true'",
+            if: [
+                "needs.constants.outputs.is-fork-pr != 'true'",
+                "github.event.pull_request.milestone == null"
+            ].join(" && "),
             steps: [
                 {
                     name: "Print latest Webiny version",
diff --git a/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts b/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts
new file mode 100644
index 00000000000..967a6704370
--- /dev/null
+++ b/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts
@@ -0,0 +1,45 @@
+import extractPeLoaderDataFromHtml from "../../src/render/extractPeLoaderDataFromHtml";
+
+describe("extractPeLoaderDataFromHtml Tests", () => {
+    it("must detect pe-loader-data-cache tags in given HTML", async () => {
+        const results = extractPeLoaderDataFromHtml(TEST_STRING);
+
+        expect(results).toEqual([
+            {
+                key: "GfT8AoRsYT-1238102521",
+                value: [
+                    {
+                        description:
+                            "The Falcon 1 was an expendable launch system privately developed and manufactured by SpaceX during 2006-2009. On 28 September 2008, Falcon 1 became the first privately-developed liquid-fuel launch vehicle to go into orbit around the Earth.",
+                        id: "5e9d0d95eda69955f709d1eb",
+                        name: "Falcon 1",
+                        wikipedia: "https://en.wikipedia.org/wiki/Falcon_1"
+                    },
+                    {
+                        description:
+                            "Falcon 9 is a two-stage rocket designed and manufactured by SpaceX for the reliable and safe transport of satellites and the Dragon spacecraft into orbit.",
+                        id: "5e9d0d95eda69973a809d1ec",
+                        name: "Falcon 9",
+                        wikipedia: "https://en.wikipedia.org/wiki/Falcon_9"
+                    },
+                    {
+                        description:
+                            "With the ability to lift into orbit over 54 metric tons (119,000 lb)--a mass equivalent to a 737 jetliner loaded with passengers, crew, luggage and fuel--Falcon Heavy can lift more than twice the payload of the next closest operational vehicle, the Delta IV Heavy, at one-third the cost.",
+                        id: "5e9d0d95eda69974db09d1ed",
+                        name: "Falcon Heavy",
+                        wikipedia: "https://en.wikipedia.org/wiki/Falcon_Heavy"
+                    },
+                    {
+                        description:
+                            "Starship and Super Heavy Rocket represent a fully reusable transportation system designed to service all Earth orbit needs as well as the Moon and Mars. This two-stage vehicle — composed of the Super Heavy rocket (booster) and Starship (ship) — will eventually replace Falcon 9, Falcon Heavy and Dragon.",
+                        id: "5e9d0d96eda699382d09d1ee",
+                        name: "Starship",
+                        wikipedia: "https://en.wikipedia.org/wiki/SpaceX_Starship"
+                    }
+                ]
+            }
+        ]);
+    });
+});
+
+const TEST_STRING = `...<li><h1>Starship</h1><div>Starship and Super Heavy Rocket represent a fully reusable transportation system designed to service all Earth orbit needs as well as the Moon and Mars. This two-stage vehicle — composed of the Super Heavy rocket (booster) and Starship (ship) — will eventually replace Falcon 9, Falcon Heavy and Dragon.</div><br><div>More info at&nbsp;<a href="https://en.wikipedia.org/wiki/SpaceX_Starship" target="_blank" rel="noreferrer">https://en.wikipedia.org/wiki/SpaceX_Starship</a></div></li></ul></pb-spacex></pb-cell></pb-grid></pb-block></pb-document></main><footer data-testid="pb-footer" class="wby-1lh86qf"><div class="wby-xv6w56"><div class="logo wby-1i3ok2b"><a href="/"></a><div class="copy">DEVR © 2024</div></div></div></footer></div></div><pe-loader-data-cache data-key="GfT8AoRsYT-1238102521" data-value="[{&quot;id&quot;:&quot;5e9d0d95eda69955f709d1eb&quot;,&quot;name&quot;:&quot;Falcon 1&quot;,&quot;description&quot;:&quot;The Falcon 1 was an expendable launch system privately developed and manufactured by SpaceX during 2006-2009. On 28 September 2008, Falcon 1 became the first privately-developed liquid-fuel launch vehicle to go into orbit around the Earth.&quot;,&quot;wikipedia&quot;:&quot;https://en.wikipedia.org/wiki/Falcon_1&quot;},{&quot;id&quot;:&quot;5e9d0d95eda69973a809d1ec&quot;,&quot;name&quot;:&quot;Falcon 9&quot;,&quot;description&quot;:&quot;Falcon 9 is a two-stage rocket designed and manufactured by SpaceX for the reliable and safe transport of satellites and the Dragon spacecraft into orbit.&quot;,&quot;wikipedia&quot;:&quot;https://en.wikipedia.org/wiki/Falcon_9&quot;},{&quot;id&quot;:&quot;5e9d0d95eda69974db09d1ed&quot;,&quot;name&quot;:&quot;Falcon Heavy&quot;,&quot;description&quot;:&quot;With the ability to lift into orbit over 54 metric tons (119,000 lb)--a mass equivalent to a 737 jetliner loaded with passengers, crew, luggage and fuel--Falcon Heavy can lift more than twice the payload of the next closest operational vehicle, the Delta IV Heavy, at one-third the cost.&quot;,&quot;wikipedia&quot;:&quot;https://en.wikipedia.org/wiki/Falcon_Heavy&quot;},{&quot;id&quot;:&quot;5e9d0d96eda699382d09d1ee&quot;,&quot;name&quot;:&quot;Starship&quot;,&quot;description&quot;:&quot;Starship and Super Heavy Rocket represent a fully reusable transportation system designed to service all Earth orbit needs as well as the Moon and Mars. This two-stage vehicle — composed of the Super Heavy rocket (booster) and Starship (ship) — will eventually replace Falcon 9, Falcon Heavy and Dragon.&quot;,&quot;wikipedia&quot;:&quot;https://en.wikipedia.org/wiki/SpaceX_Starship&quot;}]"></pe-loader-data-cache></body></html>`;
diff --git a/packages/api-prerendering-service/package.json b/packages/api-prerendering-service/package.json
index 4f5b541a939..6de200a1885 100644
--- a/packages/api-prerendering-service/package.json
+++ b/packages/api-prerendering-service/package.json
@@ -24,6 +24,7 @@
     "@webiny/handler-client": "0.0.0",
     "@webiny/plugins": "0.0.0",
     "@webiny/utils": "0.0.0",
+    "he": "^1.2.0",
     "lodash": "^4.17.21",
     "object-hash": "^3.0.0",
     "pluralize": "^8.0.0",
@@ -40,6 +41,7 @@
     "@babel/plugin-proposal-export-default-from": "^7.23.3",
     "@babel/preset-env": "^7.24.0",
     "@babel/preset-typescript": "^7.23.3",
+    "@types/he": "^1.2.3",
     "@types/object-hash": "^2.2.1",
     "@types/puppeteer-core": "^5.4.0",
     "@webiny/cli": "0.0.0",
diff --git a/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts b/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts
new file mode 100644
index 00000000000..1618e550a27
--- /dev/null
+++ b/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts
@@ -0,0 +1,69 @@
+import { PeLoaderCacheEntry } from "./types";
+import he from "he";
+
+const parsePeLoaderDataCacheTag = (content: string): PeLoaderCacheEntry | null => {
+    const regex =
+        /<pe-loader-data-cache data-key="([a-zA-Z0-9-#]+)" data-value="(.*)"><\/pe-loader-data-cache>/gm;
+    let m;
+
+    while ((m = regex.exec(content)) !== null) {
+        // This is necessary to avoid infinite loops with zero-width matches
+        if (m.index === regex.lastIndex) {
+            regex.lastIndex++;
+        }
+
+        const [, key, value] = m;
+
+        // JSON in `data-value` is HTML Entities-encoded. So, we need to decode it here first.
+        const heParsedValue = he.decode(value);
+        const parsedValue = JSON.parse(heParsedValue);
+        return { key, value: parsedValue };
+    }
+
+    return null;
+};
+
+export default (content: string): PeLoaderCacheEntry[] => {
+    if (!content) {
+        return [];
+    }
+
+    const cachedData: PeLoaderCacheEntry[] = [];
+    const regex = /<pe-loader-data-cache .*><\/pe-loader-data-cache>/gm;
+    let m;
+
+    while ((m = regex.exec(content)) !== null) {
+        // This is necessary to avoid infinite loops with zero-width matches
+        if (m.index === regex.lastIndex) {
+            regex.lastIndex++;
+        }
+
+        const [matchedTag] = m;
+
+        if (!matchedTag) {
+            continue;
+        }
+
+        const parsedTag = parsePeLoaderDataCacheTag(matchedTag);
+        if (!parsedTag) {
+            continue;
+        }
+
+        cachedData.push(parsedTag);
+    }
+
+    if (cachedData.length > 0) {
+        const uniqueMap: Record<string, PeLoaderCacheEntry> = cachedData.reduce(
+            (collection, peLoaderDataCache) => {
+                collection[`${peLoaderDataCache.key || ""}${peLoaderDataCache.value || ""}`] =
+                    peLoaderDataCache;
+
+                return collection;
+            },
+            {} as Record<string, PeLoaderCacheEntry>
+        );
+
+        return Object.values(uniqueMap);
+    }
+    return cachedData;
+};
diff --git a/packages/api-prerendering-service/src/render/renderUrl.ts b/packages/api-prerendering-service/src/render/renderUrl.ts
index 57aaf7b1fcd..c3333b71c5f 100644
--- a/packages/api-prerendering-service/src/render/renderUrl.ts
+++ b/packages/api-prerendering-service/src/render/renderUrl.ts
@@ -14,8 +14,11 @@ import injectRenderTs from "./injectRenderTs";
 import injectTenantLocale from "./injectTenantLocale";
 import injectNotFoundPageFlag from "./injectNotFoundPageFlag";
 import getPsTags from "./getPsTags";
+import extractPeLoaderDataFromHtml from "./extractPeLoaderDataFromHtml";
 import shortid from "shortid";
 import {
+    GraphQLCacheEntry,
+    PeLoaderCacheEntry,
     RenderResult,
     RenderUrlCallableParams,
     RenderUrlParams,
@@ -108,8 +111,8 @@ export default async (url: string, args: RenderUrlParams): Promise<[File[], Meta
                 }
             },
             {
-                name: "graphql.json",
-                body: JSON.stringify(render.meta.gqlCache),
+                name: "cache.json",
+                body: JSON.stringify(render.meta.cachedData),
                 type: "application/json",
                 meta: {}
             }
@@ -118,12 +121,6 @@ export default async (url: string, args: RenderUrlParams): Promise<[File[], Meta
     ];
 };
 
-interface GraphQLCache {
-    query: any;
-    variables: Record<string, any>;
-    data: Record<string, any>;
-}
-
 export const defaultRenderUrlFunction = async (
     url: string,
     params: RenderUrlCallableParams
@@ -168,7 +165,13 @@ export const defaultRenderUrlFunction = async (
             }
         });
 
-        const gqlCache: GraphQLCache[] = [];
+        const cachedData: {
+            apolloGraphQl: GraphQLCacheEntry[];
+            peLoaders: PeLoaderCacheEntry[];
+        } = {
+            apolloGraphQl: [],
+            peLoaders: []
+        };
 
         // TODO: should be a plugin.
         browserPage.on("response", async response => {
@@ -189,7 +192,7 @@ export const defaultRenderUrlFunction = async (
 
                     if (mustCache) {
                         const data = Array.isArray(responses) ? responses[i].data : responses.data;
-                        gqlCache.push({
+                        cachedData.apolloGraphQl.push({
                             query,
                             variables,
                             data
@@ -208,11 +211,15 @@ export const defaultRenderUrlFunction = async (
             return window.getApolloState();
         });
 
+        const content = await browserPage.content();
+
+        cachedData.peLoaders = extractPeLoaderDataFromHtml(content);
+
         return {
-            content: await browserPage.content(),
+            content,
             // TODO: ideally, meta should be assigned here in a more "plugins style" way, not hardcoded.
             meta: {
-                gqlCache,
+                cachedData,
                 apolloState
             }
         };
diff --git a/packages/api-prerendering-service/src/render/types.ts b/packages/api-prerendering-service/src/render/types.ts
index b629aa8cee5..d92a917d6f0 100644
--- a/packages/api-prerendering-service/src/render/types.ts
+++ b/packages/api-prerendering-service/src/render/types.ts
@@ -23,11 +23,25 @@ export interface RenderApolloState {
 /**
  * @internal
  */
+
+export interface GraphQLCacheEntry {
+    query: any;
+    variables: Record<string, any>;
+    data: Record<string, any>;
+}
+
+export interface PeLoaderCacheEntry {
+    key: string;
+    value: string;
+}
+
 export interface RenderResult {
     content: string;
     meta: {
         apolloState: RenderApolloState;
-        gqlCache: {
+        cachedData: {
+            apolloGraphQl: GraphQLCacheEntry[];
+            peLoaders: PeLoaderCacheEntry[];
             [key: string]: any;
         };
         [key: string]: any;
diff --git a/packages/app-page-builder-elements/src/contexts/PageElements.tsx b/packages/app-page-builder-elements/src/contexts/PageElements.tsx
index e05dd3fc659..42b994ff4db 100644
--- a/packages/app-page-builder-elements/src/contexts/PageElements.tsx
+++ b/packages/app-page-builder-elements/src/contexts/PageElements.tsx
@@ -31,7 +31,8 @@ export const PageElementsProvider = ({
     renderers = {},
     modifiers,
     beforeRenderer = null,
-    afterRenderer = null
+    afterRenderer = null,
+    loaderCache
 }: PageElementsProviderProps) => {
     // Attributes-related callbacks.
     const getElementAttributes = useCallback<GetElementAttributes>(
@@ -42,7 +43,8 @@ export const PageElementsProvider = ({
                 renderers,
                 modifiers,
                 beforeRenderer,
-                afterRenderer
+                afterRenderer,
+                loaderCache
             });
         },
         [theme]
@@ -79,7 +81,8 @@ export const PageElementsProvider = ({
                 modifiers,
                 assignStyles: customAssignStylesCallback,
                 beforeRenderer,
-                afterRenderer
+                afterRenderer,
+                loaderCache
             });
         },
         [theme, customElementStylesCallback, customAssignStylesCallback]
@@ -95,7 +98,8 @@ export const PageElementsProvider = ({
                 modifiers,
                 assignStyles: customAssignStylesCallback,
                 beforeRenderer,
-                afterRenderer
+                afterRenderer,
+                loaderCache
             });
         },
         [theme, customStylesCallback, customAssignStylesCallback]
@@ -122,7 +126,8 @@ export const PageElementsProvider = ({
         setElementStylesCallback,
         setStylesCallback,
         beforeRenderer,
-        afterRenderer
+        afterRenderer,
+        loaderCache
     };
 
     return (
diff --git a/packages/app-page-builder-elements/src/hooks/useLoader.ts b/packages/app-page-builder-elements/src/hooks/useLoader.ts
new file mode 100644
index 00000000000..095b13e4bba
--- /dev/null
+++ b/packages/app-page-builder-elements/src/hooks/useLoader.ts
@@ -0,0 +1,61 @@
+import { useEffect, useMemo, useState, type DependencyList } from "react";
+import { createObjectHash } from "./useLoader/createObjectHash";
+import { useRenderer } from "..";
+
+export interface RendererLoader<TData = unknown> {
+    data: TData | null;
+    loading: boolean;
+    cacheHit: boolean;
+    cacheKey: null | string;
+}
+
+export interface UseLoaderOptions {
+    cacheKey?: DependencyList;
+}
+
+export function useLoader<TData = unknown>(
+    loaderFn: () => Promise<TData>,
+    options?: UseLoaderOptions
+): RendererLoader<TData> {
+    const { getElement, loaderCache } = useRenderer();
+
+    const element = getElement();
+
+    const elementDataCacheKey = element.id;
+    const optionsCacheKey = options?.cacheKey || [];
+    const cacheKey = createObjectHash([elementDataCacheKey, ...optionsCacheKey]);
+
+    const cachedData = useMemo(() => {
+        return loaderCache.read<TData>(cacheKey);
+    }, [cacheKey]);
+
+    const [loader, setLoader] = useState<RendererLoader<TData>>(
+        cachedData
+            ? {
+                  data: cachedData,
+                  loading: false,
+                  cacheHit: true,
+                  cacheKey
+              }
+            : { data: null, loading: true, cacheHit: false, cacheKey: null }
+    );
+
+    useEffect(() => {
+        if (cacheKey === loader.cacheKey) {
+            return;
+        }
+
+        if (cachedData) {
+            setLoader({ data: cachedData, loading: false, cacheKey, cacheHit: true });
+            return;
+        }
+
+        setLoader({ data: loader.data, loading: true, cacheKey, cacheHit: false });
+        loaderFn().then(data => {
+            loaderCache.write(cacheKey, data);
+            setLoader({ data, loading: false, cacheKey, cacheHit: false });
+        });
+    }, [cacheKey]);
+
+    return loader;
+}
diff --git a/packages/app-page-builder-elements/src/hooks/useLoader/ILoaderCache.ts b/packages/app-page-builder-elements/src/hooks/useLoader/ILoaderCache.ts
new file mode 100644
index 00000000000..31abcbdc536
--- /dev/null
+++ b/packages/app-page-builder-elements/src/hooks/useLoader/ILoaderCache.ts
@@ -0,0 +1,6 @@
+export interface ILoaderCache {
+    read: <TData = unknown>(key: string) => TData | null;
+    write: <TData = unknown>(key: string, value: TData) => void;
+    remove: (key: string) => void;
+    clear: () => void;
+}
diff --git a/packages/app-page-builder-elements/src/hooks/useLoader/NullLoaderCache.ts b/packages/app-page-builder-elements/src/hooks/useLoader/NullLoaderCache.ts
new file mode 100644
index 00000000000..5649d75f195
--- /dev/null
+++ b/packages/app-page-builder-elements/src/hooks/useLoader/NullLoaderCache.ts
@@ -0,0 +1,19 @@
+import { ILoaderCache } from "./ILoaderCache";
+
+export class NullLoaderCache implements ILoaderCache {
+    read() {
+        return null;
+    }
+
+    write() {
+        return;
+    }
+
+    remove() {
+        return;
+    }
+
+    clear() {
+        return;
+    }
+}
diff --git a/packages/app-page-builder-elements/src/hooks/useLoader/createObjectHash.ts b/packages/app-page-builder-elements/src/hooks/useLoader/createObjectHash.ts
new file mode 100644
index 00000000000..f619c11e202
--- /dev/null
+++ b/packages/app-page-builder-elements/src/hooks/useLoader/createObjectHash.ts
@@ -0,0 +1,17 @@
+export const createObjectHash = (object: Record<string, any>) => {
+    const jsonString = JSON.stringify(object);
+
+    // Create a hash string from string.
+    if (jsonString.length === 0) {
+        return "";
+    }
+
+    let hash = 0;
+    for (let i = 0; i < jsonString.length; i++) {
+        const charCode = jsonString.charCodeAt(i);
+        hash = (hash << 5) - hash + charCode;
+        hash |= 0; // Convert to 32bit integer
+    }
+
+    return String(hash);
+};
diff --git a/packages/app-page-builder-elements/src/index.ts b/packages/app-page-builder-elements/src/index.ts
index 4865e5ee8f2..19ca8705fd2 100644
--- a/packages/app-page-builder-elements/src/index.ts
+++ b/packages/app-page-builder-elements/src/index.ts
@@ -6,6 +6,7 @@ export * from "./hooks/usePage";
 export * from "./hooks/usePageElements";
 export * from "./hooks/useRenderer";
 export * from "./hooks/useFacepaint";
+export * from "./hooks/useLoader";
 
 export * from "./contexts/PageElements";
 export * from "./contexts/Page";
diff --git a/packages/app-page-builder-elements/src/types.ts b/packages/app-page-builder-elements/src/types.ts
index a96ec8f6a17..19ea0dac7e3 100644
--- a/packages/app-page-builder-elements/src/types.ts
+++ b/packages/app-page-builder-elements/src/types.ts
@@ -6,6 +6,7 @@ import React, { HTMLAttributes } from "react";
 import { type CSSObject } from "@emotion/react";
 import { StylesObject, ThemeBreakpoints, Theme } from "@webiny/theme/types";
 import { ElementInputs, ElementInputValues } from "~/inputs/ElementInput";
+import { ILoaderCache } from "~/hooks/useLoader/ILoaderCache";
 
 export interface Page {
     id: string;
@@ -34,6 +35,7 @@ export interface PageElementsProviderProps {
     beforeRenderer?: React.ComponentType | null;
     afterRenderer?: React.ComponentType | null;
     children?: React.ReactNode;
+    loaderCache: ILoaderCache;
 }
 
 export type AttributesObject = React.ComponentProps<any>;
diff --git a/packages/app-page-builder/src/PageBuilder.tsx b/packages/app-page-builder/src/PageBuilder.tsx
index 259b3561bb7..b3bae68ed7d 100644
--- a/packages/app-page-builder/src/PageBuilder.tsx
+++ b/packages/app-page-builder/src/PageBuilder.tsx
@@ -1,4 +1,4 @@
-import React, { Fragment } from "react";
+import React, { Fragment, useMemo } from "react";
 import { HasPermission } from "@webiny/app-security";
 import { Plugins, AddMenu as Menu, createProviderPlugin } from "@webiny/app-admin";
 import { Global, css } from "@emotion/react";
@@ -16,6 +16,7 @@ import { AddButtonClickHandlers } from "~/elementDecorators/AddButtonClickHandle
 import { InjectElementVariables } from "~/render/variables/InjectElementVariables";
 import { LexicalParagraphRenderer } from "~/render/plugins/elements/paragraph/LexicalParagraph";
 import { LexicalHeadingRenderer } from "~/render/plugins/elements/heading/LexicalHeading";
+import { NullLoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/NullLoaderCache";
 
 export type { EditorProps };
 export { EditorRenderer };
@@ -24,8 +25,12 @@ export * from "~/admin/views/Pages/hooks";
 
 const PageBuilderProviderPlugin = createProviderPlugin(Component => {
     return function PageBuilderProvider({ children }) {
+        const noLoaderCache = useMemo(() => {
+            return new NullLoaderCache();
+        }, []);
+
         return (
-            <ContextProvider>
+            <ContextProvider loaderCache={noLoaderCache}>
                 <AdminPageBuilderContextProvider>
                     <Component>{children}</Component>
                 </AdminPageBuilderContextProvider>
diff --git a/packages/app-page-builder/src/admin/components/ResponsiveElementsProvider/ResponsiveElementsProvider.tsx b/packages/app-page-builder/src/admin/components/ResponsiveElementsProvider/ResponsiveElementsProvider.tsx
index dc331449aec..d3481e39903 100644
--- a/packages/app-page-builder/src/admin/components/ResponsiveElementsProvider/ResponsiveElementsProvider.tsx
+++ b/packages/app-page-builder/src/admin/components/ResponsiveElementsProvider/ResponsiveElementsProvider.tsx
@@ -4,6 +4,7 @@ import { usePageBuilder } from "~/hooks/usePageBuilder";
 import { mediaToContainer } from "./mediaToContainer";
 import { PageElementsProvider } from "~/contexts/PageBuilder/PageElementsProvider";
 import styled from "@emotion/styled";
+import { NullLoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/NullLoaderCache";
 
 const ResponsiveContainer = styled.div`
     container-type: inline-size;
@@ -39,9 +40,15 @@ export const ResponsiveElementsProvider = ({ children }: { children: React.React
         } as Theme;
     }, [pageBuilder.theme]);
 
+    const nullLoaderCache = useMemo(() => {
+        return new NullLoaderCache();
+    }, []);
+
     return (
         <ResponsiveContainer>
-            <PageElementsProvider theme={containerizedTheme!}>{children}</PageElementsProvider>
+            <PageElementsProvider theme={containerizedTheme!} loaderCache={nullLoaderCache}>
+                {children}
+            </PageElementsProvider>
         </ResponsiveContainer>
     );
 };
diff --git a/packages/app-page-builder/src/contexts/PageBuilder/PageBuilderContext.tsx b/packages/app-page-builder/src/contexts/PageBuilder/PageBuilderContext.tsx
index 5caadd3d916..0eda5c49f37 100644
--- a/packages/app-page-builder/src/contexts/PageBuilder/PageBuilderContext.tsx
+++ b/packages/app-page-builder/src/contexts/PageBuilder/PageBuilderContext.tsx
@@ -4,6 +4,7 @@ import { DisplayMode, PbTheme } from "~/types";
 import { Theme } from "@webiny/app-theme/types";
 import { useTheme } from "@webiny/app-theme";
 import { PageElementsProvider } from "./PageElementsProvider";
+import { ILoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/ILoaderCache";
 
 export interface ResponsiveDisplayMode {
     displayMode: DisplayMode;
@@ -35,12 +36,13 @@ export interface PageBuilderContext {
 }
 
 export interface PageBuilderProviderProps {
+    loaderCache: ILoaderCache;
     children?: React.ReactChild | React.ReactChild[];
 }
 
 export const PageBuilderContext = React.createContext<PageBuilderContext | undefined>(undefined);
 
-export const PageBuilderProvider = ({ children }: PageBuilderProviderProps) => {
+export const PageBuilderProvider = ({ children, loaderCache }: PageBuilderProviderProps) => {
     const [displayMode, setDisplayMode] = React.useState(DisplayMode.DESKTOP);
     const [revisionType, setRevisionType] = React.useState<PbRevisionType>(
         PbRevisionType.published
@@ -62,7 +64,7 @@ export const PageBuilderProvider = ({ children }: PageBuilderProviderProps) => {
                 }
             }}
         >
-            <PageElementsProvider>{children}</PageElementsProvider>
+            <PageElementsProvider loaderCache={loaderCache}>{children}</PageElementsProvider>
         </PageBuilderContext.Provider>
     );
 };
diff --git a/packages/app-page-builder/src/contexts/PageBuilder/PageElementsProvider.tsx b/packages/app-page-builder/src/contexts/PageBuilder/PageElementsProvider.tsx
index 7c6e2cce40b..9c390c040ea 100644
--- a/packages/app-page-builder/src/contexts/PageBuilder/PageElementsProvider.tsx
+++ b/packages/app-page-builder/src/contexts/PageBuilder/PageElementsProvider.tsx
@@ -27,13 +27,19 @@ import { Theme } from "@webiny/app-theme/types";
 
 import { plugins } from "@webiny/plugins";
 import { PbRenderElementPlugin } from "~/types";
+import { ILoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/ILoaderCache";
 
 interface PageElementsProviderProps {
     theme?: Theme;
+    loaderCache: ILoaderCache;
     children: React.ReactNode;
 }
 
-export const PageElementsProvider = ({ theme, children }: PageElementsProviderProps) => {
+export const PageElementsProvider = ({
+    theme,
+    loaderCache,
+    children
+}: PageElementsProviderProps) => {
     const pageBuilder = usePageBuilder();
 
     const getRenderers = useCallback(() => {
@@ -76,6 +82,7 @@ export const PageElementsProvider = ({ theme, children }: PageElementsProviderPr
             theme={theme ?? (pageBuilder.theme as Theme)}
             renderers={getRenderers}
             modifiers={modifiers}
+            loaderCache={loaderCache}
         >
             {children}
         </PbPageElementsProvider>
diff --git a/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider.tsx b/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider.tsx
index c1a36616691..8531ad23b56 100644
--- a/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider.tsx
+++ b/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider.tsx
@@ -32,6 +32,7 @@ import { plugins } from "@webiny/plugins";
 import { PbEditorPageElementPlugin } from "~/types";
 import { ElementControls } from "./EditorPageElementsProvider/ElementControls";
 import { mediaToContainer } from "./EditorPageElementsProvider/mediaToContainer";
+import { NullLoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/NullLoaderCache";
 
 interface EditorPageElementsProviderProps {
     children: React.ReactNode;
@@ -96,11 +97,16 @@ export const EditorPageElementsProvider = ({ children }: EditorPageElementsProvi
         } as Theme;
     }, [pageBuilder.theme]);
 
+    const nullLoaderCache = useMemo(() => {
+        return new NullLoaderCache();
+    }, []);
+
     return (
         <PbPageElementsProvider
             theme={containerizedTheme!}
             renderers={renderers}
             modifiers={modifiers}
+            loaderCache={nullLoaderCache}
             beforeRenderer={ElementControls}
         >
             {children}
diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveDialog.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveDialog.tsx
index 41d07a3bf1e..8a7035ce9dd 100644
--- a/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveDialog.tsx
+++ b/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveDialog.tsx
@@ -1,9 +1,10 @@
-import React, { useState, useEffect } from "react";
+import React, { useState, useEffect, useMemo } from "react";
 import { css } from "emotion";
 import { plugins } from "@webiny/plugins";
 import ElementPreview from "./SaveDialog/ElementPreview";
 import { CircularProgress } from "@webiny/ui/Progress";
 import { PageElementsProvider } from "~/contexts/PageBuilder/PageElementsProvider";
+import { NullLoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/NullLoaderCache";
 
 import {
     Dialog,
@@ -91,6 +92,10 @@ const SaveDialog = (props: Props) => {
         }
     };
 
+    const nullLoaderCache = useMemo(() => {
+        return new NullLoaderCache();
+    }, []);
+
     return (
         <Dialog open={open} onClose={onClose} className={narrowDialog}>
             <Form onSubmit={onSubmit} data={{ type, id: element.id }}>
@@ -142,7 +147,7 @@ const SaveDialog = (props: Props) => {
                             <Grid>
                                 <Cell span={12}>
                                     <PreviewBox>
-                                        <PageElementsProvider>
+                                        <PageElementsProvider loaderCache={nullLoaderCache}>
                                             <ElementPreview element={pbElement} />
                                         </PageElementsProvider>
                                     </PreviewBox>
diff --git a/packages/app-website/src/LinkPreload.tsx b/packages/app-website/src/LinkPreload.tsx
index 82a6fae34ab..594e56dc795 100644
--- a/packages/app-website/src/LinkPreload.tsx
+++ b/packages/app-website/src/LinkPreload.tsx
@@ -5,6 +5,7 @@ import { makeDecoratable } from "@webiny/app";
 import { Link, To } from "@webiny/react-router";
 import { getPrerenderId, isPrerendering } from "@webiny/app/utils";
 import { GET_PUBLISHED_PAGE } from "./Page/graphql";
+import { usePageElements } from "@webiny/app-page-builder-elements";
 
 const preloadedPaths: string[] = [];
 
@@ -23,6 +24,8 @@ const defaultGetPreloadPagePath: GetPreloadPagePath = path => {
 const useLinkPreload = (path: string | To, options: LinkPreloadOptions) => {
     const getPreloadPagePath = options.getPreloadPagePath ?? defaultGetPreloadPagePath;
 
+    const { loaderCache } = usePageElements();
+
     const apolloClient = useApolloClient();
     const preloadPath = async (pathname: string) => {
         // We only need a clean pathname, without query parameters.
@@ -34,22 +37,32 @@ const useLinkPreload = (path: string | To, options: LinkPreloadOptions) => {
 
         preloadedPaths.push(pathname);
 
-        const graphqlJson = `graphql.json?k=${getPrerenderId()}`;
+        const graphqlJson = `cache.json?k=${getPrerenderId()}`;
         const fetchPath = pathname !== "/" ? `${pathname}/${graphqlJson}` : `/${graphqlJson}`;
         const pageState = await fetch(fetchPath.replace("//", "/"))
             .then(res => res.json())
             .catch(() => null);
 
         if (pageState) {
-            for (let i = 0; i < pageState.length; i++) {
-                const { query, variables, data } = pageState[i];
-                apolloClient.writeQuery({
-                    query: gql`
-                        ${query}
-                    `,
-                    data,
-                    variables
-                });
+            const { apolloGraphQl, peLoaders } = pageState;
+            if (Array.isArray(apolloGraphQl)) {
+                for (let i = 0; i < apolloGraphQl.length; i++) {
+                    const { query, variables, data } = apolloGraphQl[i];
+                    apolloClient.writeQuery({
+                        query: gql`
+                            ${query}
+                        `,
+                        data,
+                        variables
+                    });
+                }
+            }
+
+            if (Array.isArray(peLoaders)) {
+                for (let i = 0; i < peLoaders.length; i++) {
+                    const { key, value } = peLoaders[i];
+                    loaderCache.write(key, value);
+                }
             }
         } else {
             const finalPath = getPreloadPagePath(pathname);
diff --git a/packages/app-website/src/Website.tsx b/packages/app-website/src/Website.tsx
index cd6dabbad88..5b917e0845c 100644
--- a/packages/app-website/src/Website.tsx
+++ b/packages/app-website/src/Website.tsx
@@ -1,4 +1,4 @@
-import React from "react";
+import React, { useMemo } from "react";
 import { App, AppProps, Decorator, GenericComponent } from "@webiny/app";
 import { ApolloProvider } from "@apollo/react-hooks";
 import { CacheProvider } from "@emotion/react";
@@ -9,6 +9,7 @@ import { PageBuilderProvider } from "@webiny/app-page-builder/contexts/PageBuild
 import { PageBuilder } from "@webiny/app-page-builder/render";
 import { RouteProps } from "@webiny/react-router";
 import { LinkPreload } from "~/LinkPreload";
+import { WebsiteLoaderCache } from "~/utils/WebsiteLoaderCache";
 
 export interface WebsiteProps extends AppProps {
     apolloClient?: ReturnType<typeof createApolloClient>;
@@ -17,9 +18,13 @@ export interface WebsiteProps extends AppProps {
 const PageBuilderProviderHOC: Decorator<
     GenericComponent<{ children: React.ReactNode }>
 > = PreviousProvider => {
+    const websiteLoaderCache = useMemo(() => {
+        return new WebsiteLoaderCache();
+    }, []);
+
     return function PageBuilderProviderHOC({ children }) {
         return (
-            <PageBuilderProvider>
+            <PageBuilderProvider loaderCache={websiteLoaderCache}>
                 <PreviousProvider>{children}</PreviousProvider>
             </PageBuilderProvider>
         );
diff --git a/packages/app-website/src/utils/WebsiteLoaderCache.ts b/packages/app-website/src/utils/WebsiteLoaderCache.ts
new file mode 100644
index 00000000000..56d31a03264
--- /dev/null
+++ b/packages/app-website/src/utils/WebsiteLoaderCache.ts
@@ -0,0 +1,37 @@
+import type { ILoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/ILoaderCache";
+import { getPrerenderId, isPrerendering } from "@webiny/app/utils";
+import { PeLoaderHtmlCache } from "~/utils/WebsiteLoaderCache/PeLoaderHtmlCache";
+
+export class WebsiteLoaderCache implements ILoaderCache {
+    private loaderCache: Record<string, any> = {};
+
+    read<TData = unknown>(key: string) {
+        if (key in this.loaderCache) {
+            return this.loaderCache[key];
+        }
+
+        if (getPrerenderId()) {
+            this.loaderCache[key] = PeLoaderHtmlCache.read<TData>(key);
+            return this.loaderCache[key];
+        }
+
+        this.loaderCache[key] = null;
+        return this.loaderCache[key];
+    }
+
+    write<TData = unknown>(key: string, value: TData) {
+        this.loaderCache[key] = value;
+
+        if (isPrerendering()) {
+            PeLoaderHtmlCache.write(key, value);
+        }
+    }
+
+    remove(key: string) {
+        delete this.loaderCache[key];
+    }
+
+    clear() {
+        this.loaderCache = {};
+    }
+}
diff --git a/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts b/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts
new file mode 100644
index 00000000000..10b3b83a1bb
--- /dev/null
+++ b/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts
@@ -0,0 +1,26 @@
+export class PeLoaderHtmlCache {
+    static read<TData = unknown>(key: string) {
+        const htmlElement = document.querySelector(`pe-loader-data-cache[data-key="${key}"]`);
+        if (!htmlElement) {
+            return null;
+        }
+
+        const cachedResultElementValue = htmlElement.getAttribute("data-value");
+        if (!cachedResultElementValue) {
+            return null;
+        }
+
+        try {
+            return JSON.parse(cachedResultElementValue) as TData;
+        } catch {
+            return null;
+        }
+    }
+
+    static write<TData = unknown>(key: string, value: TData) {
+        const html = `<pe-loader-data-cache data-key="${key}" data-value='${JSON.stringify(
+            value
+        )}'></pe-loader-data-cache>`;
+        document.body.insertAdjacentHTML("beforeend", html);
+    }
+}
diff --git a/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/ddbPutItemConditionalCheckFailed.js b/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/ddbPutItemConditionalCheckFailed.js
new file mode 100644
index 00000000000..db5be4d75bc
--- /dev/null
+++ b/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/ddbPutItemConditionalCheckFailed.js
@@ -0,0 +1,12 @@
+const MATCH_STRING = "ConditionalCheckFailedException: The conditional request failed";
+
+module.exports = ({ error }) => {
+    const { message } = error;
+
+    if (typeof message === "string" && message.includes(MATCH_STRING)) {
+        return {
+            message: `Looks like the deployment failed because Pulumi tried to insert a record into a DynamoDB table, but the record already exists. The easiest way to resolve this is to delete the record from the table and try again.`,
+            learnMore: "https://webiny.link/deployment-ddb-conditional-check-failed"
+        };
+    }
+};
diff --git a/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/index.js b/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/index.js
index 9d8c0c559b6..ddf536f4487 100644
--- a/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/index.js
+++ b/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/index.js
@@ -1,4 +1,5 @@
+const ddbPutItemConditionalCheckFailed = require("./ddbPutItemConditionalCheckFailed");
 const missingFilesInBuild = require("./missingFilesInBuild");
 const pendingOperationsInfo = require("./pendingOperationsInfo");
 
-module.exports = [missingFilesInBuild, pendingOperationsInfo];
+module.exports = [ddbPutItemConditionalCheckFailed, missingFilesInBuild, pendingOperationsInfo];
diff --git a/packages/cli-plugin-extensions/src/promptQuestions.ts b/packages/cli-plugin-extensions/src/promptQuestions.ts
index 0d526db9ccb..fc5f949fb18 100644
--- a/packages/cli-plugin-extensions/src/promptQuestions.ts
+++ b/packages/cli-plugin-extensions/src/promptQuestions.ts
@@ -23,10 +23,7 @@ export const promptQuestions: QuestionCollection = [
         choices: [
             { name: "Admin extension", value: "admin" },
             { name: "API extension", value: "api" },
-
-            // TODO: Bring back when we design the new PB Element React Configs API.
-            // { name: "Page Builder element", value: "pbElement" },
-
+            { name: "Page Builder element", value: "pbElement" },
             { name: "Website extension", value: "website" }
         ]
     },
diff --git a/yarn.lock b/yarn.lock
index e052fcca3e4..101bb1ad4f7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -12121,6 +12121,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/he@npm:^1.2.3":
+  version: 1.2.3
+  resolution: "@types/he@npm:1.2.3"
+  checksum: e77851c73dd7b9902d92fe0118a26246a7f3676a3a1c6eb1408305187ef73b57c22550b1435946b983267f961d935554d5d0e1b458416932552f31e763e1aa41
+  languageName: node
+  linkType: hard
+
 "@types/hoist-non-react-statics@npm:^3.3.5":
   version: 3.3.5
   resolution: "@types/hoist-non-react-statics@npm:3.3.5"
@@ -14836,6 +14843,7 @@ __metadata:
     "@babel/preset-typescript": ^7.23.3
     "@babel/runtime": ^7.24.0
     "@sparticuz/chromium": 123.0.1
+    "@types/he": ^1.2.3
     "@types/object-hash": ^2.2.1
     "@types/puppeteer-core": ^5.4.0
     "@webiny/api": 0.0.0
@@ -14848,6 +14856,7 @@ __metadata:
     "@webiny/plugins": 0.0.0
     "@webiny/project-utils": 0.0.0
     "@webiny/utils": 0.0.0
+    he: ^1.2.0
     lodash: ^4.17.21
     object-hash: ^3.0.0
     pluralize: ^8.0.0

From 76ad3ee8a38c46df38421c2bea1bace19492037c Mon Sep 17 00:00:00 2001
From: adrians5j <adrian@webiny.com>
Date: Thu, 5 Dec 2024 06:32:17 +0100
Subject: [PATCH 05/52] fix: allow specifying dependency with exact version

---
 .../src/generateExtension.ts                  | 23 ++++++++++++++++---
 1 file changed, 20 insertions(+), 3 deletions(-)

diff --git a/packages/cli-plugin-extensions/src/generateExtension.ts b/packages/cli-plugin-extensions/src/generateExtension.ts
index 67af6b17418..b405ac5c265 100644
--- a/packages/cli-plugin-extensions/src/generateExtension.ts
+++ b/packages/cli-plugin-extensions/src/generateExtension.ts
@@ -93,9 +93,26 @@ export const generateExtension = async ({ input, ora, context }: GenerateExtensi
                 }
 
                 try {
-                    const { stdout } = await execa("npm", ["view", packageName, "version", "json"]);
-
-                    packageJsonUpdates[packageName] = `^${stdout}`;
+                    const parsedPackageName = (() => {
+                        const parts = packageName.split("@");
+                        if (packageName.startsWith("@")) {
+                            return { name: parts[0] + parts[1], version: parts[2] };
+                        }
+
+                        return { name: parts[0], version: parts[1] };
+                    })();
+
+                    if (parsedPackageName.version) {
+                        packageJsonUpdates[parsedPackageName.name] = parsedPackageName.version;
+                    } else {
+                        const { stdout } = await execa("npm", [
+                            "view",
+                            parsedPackageName.name,
+                            "version"
+                        ]);
+
+                        packageJsonUpdates[packageName] = `^${stdout}`;
+                    }
                 } catch (e) {
                     throw new Error(
                         `Could not find ${log.error.hl(

From 7e37ccb4eca0981976b5af9d51b26a74ef840df9 Mon Sep 17 00:00:00 2001
From: adrians5j <adrian@webiny.com>
Date: Thu, 5 Dec 2024 07:01:54 +0100
Subject: [PATCH 06/52] fix: add "extension" suffix to the label

---
 packages/cli-plugin-extensions/src/promptQuestions.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/cli-plugin-extensions/src/promptQuestions.ts b/packages/cli-plugin-extensions/src/promptQuestions.ts
index fc5f949fb18..5c6bda69459 100644
--- a/packages/cli-plugin-extensions/src/promptQuestions.ts
+++ b/packages/cli-plugin-extensions/src/promptQuestions.ts
@@ -23,7 +23,7 @@ export const promptQuestions: QuestionCollection = [
         choices: [
             { name: "Admin extension", value: "admin" },
             { name: "API extension", value: "api" },
-            { name: "Page Builder element", value: "pbElement" },
+            { name: "Page Builder element extension", value: "pbElement" },
             { name: "Website extension", value: "website" }
         ]
     },

From 7b2e8e45b6145768a46b613887cc86108c423895 Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Sun, 8 Dec 2024 17:39:52 +0100
Subject: [PATCH 07/52] fix(form): commit field value to form even if the field
 doesn't exist

(cherry picked from commit 80b12cd2cba2c9c066bea68bfbead0de69c3d516)
---
 packages/form/src/FormPresenter.ts | 1 +
 1 file changed, 1 insertion(+)

diff --git a/packages/form/src/FormPresenter.ts b/packages/form/src/FormPresenter.ts
index 3fd9837aa8b..3478aecefb9 100644
--- a/packages/form/src/FormPresenter.ts
+++ b/packages/form/src/FormPresenter.ts
@@ -104,6 +104,7 @@ export class FormPresenter<T extends GenericFormData = GenericFormData> {
     setFieldValue(name: string, value: unknown) {
         const field = this.formFields.get(name);
         if (!field) {
+            this.commitValueToData(name, value);
             return;
         }
 

From 91cf386db2a381d54c584efc6a53d5897a41922c Mon Sep 17 00:00:00 2001
From: Adrian Smijulj <adrian1358@gmail.com>
Date: Thu, 12 Dec 2024 23:55:03 +0100
Subject: [PATCH 08/52] fix: compress cached loader data (#4435)

---
 .../extractPeLoaderDataFromHtml.test.ts       | 34 ++-----------------
 .../api-prerendering-service/package.json     |  2 --
 .../src/render/extractPeLoaderDataFromHtml.ts |  6 +---
 packages/app-website/package.json             |  1 +
 packages/app-website/src/Website.tsx          |  8 ++---
 .../src/utils/WebsiteLoaderCache.ts           |  9 +++--
 .../WebsiteLoaderCache/PeLoaderHtmlCache.ts   | 22 ++++++++++--
 yarn.lock                                     | 10 +-----
 8 files changed, 37 insertions(+), 55 deletions(-)

diff --git a/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts b/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts
index 967a6704370..ae5cdb8191d 100644
--- a/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts
+++ b/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts
@@ -4,42 +4,14 @@ describe("extractPeLoaderDataFromHtml Tests", () => {
     it("must detect pe-loader-data-cache tags in given HTML", async () => {
         const results = extractPeLoaderDataFromHtml(TEST_STRING);
 
+        // The value is not decompressed, so it's still a string.
         expect(results).toEqual([
             {
                 key: "GfT8AoRsYT-1238102521",
-                value: [
-                    {
-                        description:
-                            "The Falcon 1 was an expendable launch system privately developed and manufactured by SpaceX during 2006-2009. On 28 September 2008, Falcon 1 became the first privately-developed liquid-fuel launch vehicle to go into orbit around the Earth.",
-                        id: "5e9d0d95eda69955f709d1eb",
-                        name: "Falcon 1",
-                        wikipedia: "https://en.wikipedia.org/wiki/Falcon_1"
-                    },
-                    {
-                        description:
-                            "Falcon 9 is a two-stage rocket designed and manufactured by SpaceX for the reliable and safe transport of satellites and the Dragon spacecraft into orbit.",
-                        id: "5e9d0d95eda69973a809d1ec",
-                        name: "Falcon 9",
-                        wikipedia: "https://en.wikipedia.org/wiki/Falcon_9"
-                    },
-                    {
-                        description:
-                            "With the ability to lift into orbit over 54 metric tons (119,000 lb)--a mass equivalent to a 737 jetliner loaded with passengers, crew, luggage and fuel--Falcon Heavy can lift more than twice the payload of the next closest operational vehicle, the Delta IV Heavy, at one-third the cost.",
-                        id: "5e9d0d95eda69974db09d1ed",
-                        name: "Falcon Heavy",
-                        wikipedia: "https://en.wikipedia.org/wiki/Falcon_Heavy"
-                    },
-                    {
-                        description:
-                            "Starship and Super Heavy Rocket represent a fully reusable transportation system designed to service all Earth orbit needs as well as the Moon and Mars. This two-stage vehicle — composed of the Super Heavy rocket (booster) and Starship (ship) — will eventually replace Falcon 9, Falcon Heavy and Dragon.",
-                        id: "5e9d0d96eda699382d09d1ee",
-                        name: "Starship",
-                        wikipedia: "https://en.wikipedia.org/wiki/SpaceX_Starship"
-                    }
-                ]
+                value: "pe_NobwRAJgpgzgxgJwJYAcAuSD2A7MAuMAFQAsoACAMQEMAbOHMgRjIHcqYyrsyoAPFKNghUARjXI0qAV2xxiZGAE8YaKAFsyKZADcqqmorLRtUGpgEROQsmq5SAZlThopCKJZGGAyiidQAGkauSNgA5mQATAAMUQBsALTRUQCcAHRkAPLcEQAcZF5Q6OoiUAiRMTkANJS09NzMJXBUauRopGT2SAgqmjp6porxxqbm7mQ0SACOUkgQ8fZSpuPSsvImxEhw4mRomGSheyG7ZJgIIkhonAiYMpZt5ACiVAhtqWCVYLP4YACsUMkQKIQZJ/YSxZIgn72ADsKQgjCgIneYGwzSg32odAYjGRLCQAGtUO4kFRvsQ0GgUDA8AB6GmCVJ4wkWEmpU6hGlMpA0zF1AD6OIAvpVwNB4Mh0FhcAReQxkmQkBwqDsWJh4ioqKFyNc4PioJcxUhQtgxlxLLZsA4nC43B5vL44AEOqcdu03BNRNszQoqPZWgguDAUKdLph7D79BNVErrPcyAARAMHbhBvyIX2XI57U7nNBvD5fAh/AFAkHuKjg5LQgDMVBycIRcGRqJaGNqctxBKJEBJZIpVNp9OwjK7LKobIQHK5PPb2D5yTAwtFsEQqAwOG+AHULvI46IkFHDMcJvZM9hjjmLicTGUfgAWGz65BwHY4DgACkYjGSlRiUXGIgAJTxPEyq2DAHBQNMSC6OI56vpwZA1tCZAAFb6hMJplGYVDQJYeJtJo7AwIIWrdNUiBQCw1Q0FIoShJq5DegspggbK3AABJQFQ2iGE03AnpcainK0xBcCqmyieQviKDhlhhq65AmrwlxbJgJE9KMAbrqiNBkOsmziNUcbxqYaDKgAkgAamQXE8Yo1R6CcJrxG0XR3O09AqPmnwQN8xaAsCoIVhC0J3hAIgNu4zZom2WKcdxvGdsyxKkgQ5KUtSdIMlyY4TlOXYzvFfJ2UlS6QCuEo6d8Xjmd0GwoFYlheFIAhlKVhgAEqYLq+pkG4WiwIIlzKgsNAGP1UBSDAnr+oGwYvHoUoKMoqgaIaxpjMcJEINokmcONZBPC88iXpcJruEqHAsKYensIpZAALKYAw3qPc8MDpCQioqmqGpavpUAbFs5AAMMAEUAEMAAGQwAKWQ9BqMGJHyeGcYtW1tmJYYOp6pc74iC9KilIBTX5HVMANWQ75U6gZMQzD8OsAeelQCY55SLQE0DZIjo1PFZA/gLdTY/Z5OJpqOA+YWvz/IFySxOWlbVjkESAgCCLoh8LbogQtUfQ1yXdr26X9llQ4jilPbjuynKFT4fj+HyBv1agi4ALpAA=="
             }
         ]);
     });
 });
 
-const TEST_STRING = `...<li><h1>Starship</h1><div>Starship and Super Heavy Rocket represent a fully reusable transportation system designed to service all Earth orbit needs as well as the Moon and Mars. This two-stage vehicle — composed of the Super Heavy rocket (booster) and Starship (ship) — will eventually replace Falcon 9, Falcon Heavy and Dragon.</div><br><div>More info at&nbsp;<a href="https://en.wikipedia.org/wiki/SpaceX_Starship" target="_blank" rel="noreferrer">https://en.wikipedia.org/wiki/SpaceX_Starship</a></div></li></ul></pb-spacex></pb-cell></pb-grid></pb-block></pb-document></main><footer data-testid="pb-footer" class="wby-1lh86qf"><div class="wby-xv6w56"><div class="logo wby-1i3ok2b"><a href="/"></a><div class="copy">DEVR © 2024</div></div></div></footer></div></div><pe-loader-data-cache data-key="GfT8AoRsYT-1238102521" data-value="[{&quot;id&quot;:&quot;5e9d0d95eda69955f709d1eb&quot;,&quot;name&quot;:&quot;Falcon 1&quot;,&quot;description&quot;:&quot;The Falcon 1 was an expendable launch system privately developed and manufactured by SpaceX during 2006-2009. On 28 September 2008, Falcon 1 became the first privately-developed liquid-fuel launch vehicle to go into orbit around the Earth.&quot;,&quot;wikipedia&quot;:&quot;https://en.wikipedia.org/wiki/Falcon_1&quot;},{&quot;id&quot;:&quot;5e9d0d95eda69973a809d1ec&quot;,&quot;name&quot;:&quot;Falcon 9&quot;,&quot;description&quot;:&quot;Falcon 9 is a two-stage rocket designed and manufactured by SpaceX for the reliable and safe transport of satellites and the Dragon spacecraft into orbit.&quot;,&quot;wikipedia&quot;:&quot;https://en.wikipedia.org/wiki/Falcon_9&quot;},{&quot;id&quot;:&quot;5e9d0d95eda69974db09d1ed&quot;,&quot;name&quot;:&quot;Falcon Heavy&quot;,&quot;description&quot;:&quot;With the ability to lift into orbit over 54 metric tons (119,000 lb)--a mass equivalent to a 737 jetliner loaded with passengers, crew, luggage and fuel--Falcon Heavy can lift more than twice the payload of the next closest operational vehicle, the Delta IV Heavy, at one-third the cost.&quot;,&quot;wikipedia&quot;:&quot;https://en.wikipedia.org/wiki/Falcon_Heavy&quot;},{&quot;id&quot;:&quot;5e9d0d96eda699382d09d1ee&quot;,&quot;name&quot;:&quot;Starship&quot;,&quot;description&quot;:&quot;Starship and Super Heavy Rocket represent a fully reusable transportation system designed to service all Earth orbit needs as well as the Moon and Mars. This two-stage vehicle — composed of the Super Heavy rocket (booster) and Starship (ship) — will eventually replace Falcon 9, Falcon Heavy and Dragon.&quot;,&quot;wikipedia&quot;:&quot;https://en.wikipedia.org/wiki/SpaceX_Starship&quot;}]"></pe-loader-data-cache></body></html>`;
+const TEST_STRING = `...<li><h1>Starship</h1><div>Starship and Super Heavy Rocket represent a fully reusable transportation system designed to service all Earth orbit needs as well as the Moon and Mars. This two-stage vehicle — composed of the Super Heavy rocket (booster) and Starship (ship) — will eventually replace Falcon 9, Falcon Heavy and Dragon.</div><br><div>More info at&nbsp;<a href="https://en.wikipedia.org/wiki/SpaceX_Starship" target="_blank" rel="noreferrer">https://en.wikipedia.org/wiki/SpaceX_Starship</a></div></li></ul></pb-spacex></pb-cell></pb-grid></pb-block></pb-document></main><footer data-testid="pb-footer" class="wby-1lh86qf"><div class="wby-xv6w56"><div class="logo wby-1i3ok2b"><a href="/"></a><div class="copy">DEVR © 2024</div></div></div></footer></div></div><pe-loader-data-cache data-key="GfT8AoRsYT-1238102521" data-value="pe_NobwRAJgpgzgxgJwJYAcAuSD2A7MAuMAFQAsoACAMQEMAbOHMgRjIHcqYyrsyoAPFKNghUARjXI0qAV2xxiZGAE8YaKAFsyKZADcqqmorLRtUGpgEROQsmq5SAZlThopCKJZGGAyiidQAGkauSNgA5mQATAAMUQBsALTRUQCcAHRkAPLcEQAcZF5Q6OoiUAiRMTkANJS09NzMJXBUauRopGT2SAgqmjp6porxxqbm7mQ0SACOUkgQ8fZSpuPSsvImxEhw4mRomGSheyG7ZJgIIkhonAiYMpZt5ACiVAhtqWCVYLP4YACsUMkQKIQZJ/YSxZIgn72ADsKQgjCgIneYGwzSg32odAYjGRLCQAGtUO4kFRvsQ0GgUDA8AB6GmCVJ4wkWEmpU6hGlMpA0zF1AD6OIAvpVwNB4Mh0FhcAReQxkmQkBwqDsWJh4ioqKFyNc4PioJcxUhQtgxlxLLZsA4nC43B5vL44AEOqcdu03BNRNszQoqPZWgguDAUKdLph7D79BNVErrPcyAARAMHbhBvyIX2XI57U7nNBvD5fAh/AFAkHuKjg5LQgDMVBycIRcGRqJaGNqctxBKJEBJZIpVNp9OwjK7LKobIQHK5PPb2D5yTAwtFsEQqAwOG+AHULvI46IkFHDMcJvZM9hjjmLicTGUfgAWGz65BwHY4DgACkYjGSlRiUXGIgAJTxPEyq2DAHBQNMSC6OI56vpwZA1tCZAAFb6hMJplGYVDQJYeJtJo7AwIIWrdNUiBQCw1Q0FIoShJq5DegspggbK3AABJQFQ2iGE03AnpcainK0xBcCqmyieQviKDhlhhq65AmrwlxbJgJE9KMAbrqiNBkOsmziNUcbxqYaDKgAkgAamQXE8Yo1R6CcJrxG0XR3O09AqPmnwQN8xaAsCoIVhC0J3hAIgNu4zZom2WKcdxvGdsyxKkgQ5KUtSdIMlyY4TlOXYzvFfJ2UlS6QCuEo6d8Xjmd0GwoFYlheFIAhlKVhgAEqYLq+pkG4WiwIIlzKgsNAGP1UBSDAnr+oGwYvHoUoKMoqgaIaxpjMcJEINokmcONZBPC88iXpcJruEqHAsKYensIpZAALKYAw3qPc8MDpCQioqmqGpavpUAbFs5AAMMAEUAEMAAGQwAKWQ9BqMGJHyeGcYtW1tmJYYOp6pc74iC9KilIBTX5HVMANWQ75U6gZMQzD8OsAeelQCY55SLQE0DZIjo1PFZA/gLdTY/Z5OJpqOA+YWvz/IFySxOWlbVjkESAgCCLoh8LbogQtUfQ1yXdr26X9llQ4jilPbjuynKFT4fj+HyBv1agi4ALpAA=="></pe-loader-data-cache></body></html>`;
diff --git a/packages/api-prerendering-service/package.json b/packages/api-prerendering-service/package.json
index 6de200a1885..4f5b541a939 100644
--- a/packages/api-prerendering-service/package.json
+++ b/packages/api-prerendering-service/package.json
@@ -24,7 +24,6 @@
     "@webiny/handler-client": "0.0.0",
     "@webiny/plugins": "0.0.0",
     "@webiny/utils": "0.0.0",
-    "he": "^1.2.0",
     "lodash": "^4.17.21",
     "object-hash": "^3.0.0",
     "pluralize": "^8.0.0",
@@ -41,7 +40,6 @@
     "@babel/plugin-proposal-export-default-from": "^7.23.3",
     "@babel/preset-env": "^7.24.0",
     "@babel/preset-typescript": "^7.23.3",
-    "@types/he": "^1.2.3",
     "@types/object-hash": "^2.2.1",
     "@types/puppeteer-core": "^5.4.0",
     "@webiny/cli": "0.0.0",
diff --git a/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts b/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts
index 1618e550a27..cff831707b6 100644
--- a/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts
+++ b/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts
@@ -1,5 +1,4 @@
 import { PeLoaderCacheEntry } from "./types";
-import he from "he";
 
 const parsePeLoaderDataCacheTag = (content: string): PeLoaderCacheEntry | null => {
     const regex =
@@ -14,10 +13,7 @@ const parsePeLoaderDataCacheTag = (content: string): PeLoaderCacheEntry | null =
 
         const [, key, value] = m;
 
-        // JSON in `data-value` is HTML Entities-encoded. So, we need to decode it here first.
-        const heParsedValue = he.decode(value);
-        const parsedValue = JSON.parse(heParsedValue);
-        return { key, value: parsedValue };
+        return { key, value };
     }
 
     return null;
diff --git a/packages/app-website/package.json b/packages/app-website/package.json
index 347e88c4c29..70ee6d324bb 100644
--- a/packages/app-website/package.json
+++ b/packages/app-website/package.json
@@ -26,6 +26,7 @@
     "apollo-link": "^1.2.14",
     "apollo-link-batch-http": "^1.2.14",
     "graphql-tag": "^2.12.6",
+    "lz-string": "^1.5.0",
     "react": "18.2.0",
     "react-dom": "18.2.0",
     "react-helmet": "^6.1.0",
diff --git a/packages/app-website/src/Website.tsx b/packages/app-website/src/Website.tsx
index 5b917e0845c..229798fdfff 100644
--- a/packages/app-website/src/Website.tsx
+++ b/packages/app-website/src/Website.tsx
@@ -18,11 +18,11 @@ export interface WebsiteProps extends AppProps {
 const PageBuilderProviderHOC: Decorator<
     GenericComponent<{ children: React.ReactNode }>
 > = PreviousProvider => {
-    const websiteLoaderCache = useMemo(() => {
-        return new WebsiteLoaderCache();
-    }, []);
-
     return function PageBuilderProviderHOC({ children }) {
+        const websiteLoaderCache = useMemo(() => {
+            return new WebsiteLoaderCache();
+        }, []);
+
         return (
             <PageBuilderProvider loaderCache={websiteLoaderCache}>
                 <PreviousProvider>{children}</PreviousProvider>
diff --git a/packages/app-website/src/utils/WebsiteLoaderCache.ts b/packages/app-website/src/utils/WebsiteLoaderCache.ts
index 56d31a03264..bb1a7efcba3 100644
--- a/packages/app-website/src/utils/WebsiteLoaderCache.ts
+++ b/packages/app-website/src/utils/WebsiteLoaderCache.ts
@@ -1,6 +1,6 @@
 import type { ILoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/ILoaderCache";
 import { getPrerenderId, isPrerendering } from "@webiny/app/utils";
-import { PeLoaderHtmlCache } from "~/utils/WebsiteLoaderCache/PeLoaderHtmlCache";
+import { PeLoaderHtmlCache } from "./WebsiteLoaderCache/PeLoaderHtmlCache";
 
 export class WebsiteLoaderCache implements ILoaderCache {
     private loaderCache: Record<string, any> = {};
@@ -19,7 +19,12 @@ export class WebsiteLoaderCache implements ILoaderCache {
         return this.loaderCache[key];
     }
 
-    write<TData = unknown>(key: string, value: TData) {
+    write<TData = unknown>(key: string, rawValue: TData) {
+        // We assume it's compressed data if the value is a string.
+        const value = PeLoaderHtmlCache.isCompressedData<TData>(rawValue)
+            ? PeLoaderHtmlCache.decompressData(rawValue as string)
+            : rawValue;
+
         this.loaderCache[key] = value;
 
         if (isPrerendering()) {
diff --git a/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts b/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts
index 10b3b83a1bb..87cbe6d8113 100644
--- a/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts
+++ b/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts
@@ -1,3 +1,7 @@
+import lzString from "lz-string";
+
+const COMPRESSED_DATA_PREFIX = "pe_";
+
 export class PeLoaderHtmlCache {
     static read<TData = unknown>(key: string) {
         const htmlElement = document.querySelector(`pe-loader-data-cache[data-key="${key}"]`);
@@ -11,16 +15,30 @@ export class PeLoaderHtmlCache {
         }
 
         try {
-            return JSON.parse(cachedResultElementValue) as TData;
+            return PeLoaderHtmlCache.decompressData(cachedResultElementValue) as TData;
         } catch {
             return null;
         }
     }
 
     static write<TData = unknown>(key: string, value: TData) {
-        const html = `<pe-loader-data-cache data-key="${key}" data-value='${JSON.stringify(
+        const html = `<pe-loader-data-cache data-key="${key}" data-value='${PeLoaderHtmlCache.compressData<TData>(
             value
         )}'></pe-loader-data-cache>`;
         document.body.insertAdjacentHTML("beforeend", html);
     }
+
+    static compressData<TData>(data: TData) {
+        return COMPRESSED_DATA_PREFIX + lzString.compressToBase64(JSON.stringify(data));
+    }
+
+    static decompressData(data: string) {
+        return JSON.parse(
+            lzString.decompressFromBase64(data.replace(COMPRESSED_DATA_PREFIX, "")) as string
+        );
+    }
+
+    static isCompressedData<TData>(data: TData) {
+        return typeof data === "string" && data.startsWith(COMPRESSED_DATA_PREFIX);
+    }
 }
diff --git a/yarn.lock b/yarn.lock
index 101bb1ad4f7..9474c8c091f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -12121,13 +12121,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@types/he@npm:^1.2.3":
-  version: 1.2.3
-  resolution: "@types/he@npm:1.2.3"
-  checksum: e77851c73dd7b9902d92fe0118a26246a7f3676a3a1c6eb1408305187ef73b57c22550b1435946b983267f961d935554d5d0e1b458416932552f31e763e1aa41
-  languageName: node
-  linkType: hard
-
 "@types/hoist-non-react-statics@npm:^3.3.5":
   version: 3.3.5
   resolution: "@types/hoist-non-react-statics@npm:3.3.5"
@@ -14843,7 +14836,6 @@ __metadata:
     "@babel/preset-typescript": ^7.23.3
     "@babel/runtime": ^7.24.0
     "@sparticuz/chromium": 123.0.1
-    "@types/he": ^1.2.3
     "@types/object-hash": ^2.2.1
     "@types/puppeteer-core": ^5.4.0
     "@webiny/api": 0.0.0
@@ -14856,7 +14848,6 @@ __metadata:
     "@webiny/plugins": 0.0.0
     "@webiny/project-utils": 0.0.0
     "@webiny/utils": 0.0.0
-    he: ^1.2.0
     lodash: ^4.17.21
     object-hash: ^3.0.0
     pluralize: ^8.0.0
@@ -16722,6 +16713,7 @@ __metadata:
     apollo-link: ^1.2.14
     apollo-link-batch-http: ^1.2.14
     graphql-tag: ^2.12.6
+    lz-string: ^1.5.0
     react: 18.2.0
     react-dom: 18.2.0
     react-helmet: ^6.1.0

From bb1a258ad4f91197fa55ecf963d0112cb40124e8 Mon Sep 17 00:00:00 2001
From: Adrian Smijulj <adrian1358@gmail.com>
Date: Fri, 13 Dec 2024 08:11:37 +0100
Subject: [PATCH 09/52] fix:replace `render` prop with `renderer` (#4444)

---
 .../src/plugins/PbEditorPageElementPlugin.tsx | 27 +++++++++++++------
 .../src/plugins/PbRenderElementPlugin.tsx     | 16 ++++++++---
 .../src/plugins/PbRenderElementPlugin.tsx     | 16 ++++++++---
 .../src/utils/legacyPluginToReactComponent.ts | 11 +++++---
 4 files changed, 51 insertions(+), 19 deletions(-)

diff --git a/packages/app-page-builder/src/plugins/PbEditorPageElementPlugin.tsx b/packages/app-page-builder/src/plugins/PbEditorPageElementPlugin.tsx
index ba8f4247c5f..7c9e065edee 100644
--- a/packages/app-page-builder/src/plugins/PbEditorPageElementPlugin.tsx
+++ b/packages/app-page-builder/src/plugins/PbEditorPageElementPlugin.tsx
@@ -1,8 +1,10 @@
 import type { PbEditorPageElementPlugin as BasePbEditorPageElementPlugin } from "~/types";
+import type { Renderer } from "@webiny/app-page-builder-elements/types";
+
 import { legacyPluginToReactComponent } from "@webiny/app/utils";
 
-export const PbEditorPageElementPlugin = legacyPluginToReactComponent<
-    Pick<
+export interface PbEditorPageElementPluginProps
+    extends Pick<
         BasePbEditorPageElementPlugin,
         | "elementType"
         | "toolbar"
@@ -10,15 +12,24 @@ export const PbEditorPageElementPlugin = legacyPluginToReactComponent<
         | "target"
         | "settings"
         | "create"
-        | "render"
         | "canDelete"
         | "canReceiveChildren"
         | "onReceived"
         | "onChildDeleted"
         | "onCreate"
         | "renderElementPreview"
-    >
->({
-    pluginType: "pb-editor-page-element",
-    componentDisplayName: "PbEditorPageElementPlugin"
-});
+    > {
+    renderer: Renderer;
+}
+
+export const PbEditorPageElementPlugin =
+    legacyPluginToReactComponent<PbEditorPageElementPluginProps>({
+        pluginType: "pb-editor-page-element",
+        componentDisplayName: "PbEditorPageElementPlugin",
+        mapProps: props => {
+            return {
+                ...props,
+                render: props.renderer
+            };
+        }
+    });
diff --git a/packages/app-page-builder/src/plugins/PbRenderElementPlugin.tsx b/packages/app-page-builder/src/plugins/PbRenderElementPlugin.tsx
index cedbadab567..90e79829c9a 100644
--- a/packages/app-page-builder/src/plugins/PbRenderElementPlugin.tsx
+++ b/packages/app-page-builder/src/plugins/PbRenderElementPlugin.tsx
@@ -1,9 +1,17 @@
 import type { PbRenderElementPlugin as BasePbRenderElementPlugin } from "~/types";
 import { legacyPluginToReactComponent } from "@webiny/app/utils";
 
-export const PbRenderElementPlugin = legacyPluginToReactComponent<
-    Pick<BasePbRenderElementPlugin, "elementType" | "render">
->({
+interface PbRenderElementPluginProps extends Pick<BasePbRenderElementPlugin, "elementType"> {
+    renderer: BasePbRenderElementPlugin["render"];
+}
+
+export const PbRenderElementPlugin = legacyPluginToReactComponent<PbRenderElementPluginProps>({
     pluginType: "pb-render-page-element",
-    componentDisplayName: "PbRenderElementPlugin"
+    componentDisplayName: "PbRenderElementPlugin",
+    mapProps: props => {
+        return {
+            ...props,
+            render: props.renderer
+        };
+    }
 });
diff --git a/packages/app-website/src/plugins/PbRenderElementPlugin.tsx b/packages/app-website/src/plugins/PbRenderElementPlugin.tsx
index ffaa7fc5125..0c07c47c2ae 100644
--- a/packages/app-website/src/plugins/PbRenderElementPlugin.tsx
+++ b/packages/app-website/src/plugins/PbRenderElementPlugin.tsx
@@ -1,9 +1,17 @@
 import type { PbRenderElementPlugin as BasePbRenderElementPlugin } from "@webiny/app-page-builder/types";
 import { legacyPluginToReactComponent } from "@webiny/app/utils";
 
-export const PbRenderElementPlugin = legacyPluginToReactComponent<
-    Pick<BasePbRenderElementPlugin, "elementType" | "render">
->({
+interface PbRenderElementPluginProps extends Pick<BasePbRenderElementPlugin, "elementType"> {
+    renderer: BasePbRenderElementPlugin["render"];
+}
+
+export const PbRenderElementPlugin = legacyPluginToReactComponent<PbRenderElementPluginProps>({
     pluginType: "pb-render-page-element",
-    componentDisplayName: "PbRenderElementPlugin"
+    componentDisplayName: "PbRenderElementPlugin",
+    mapProps: props => {
+        return {
+            ...props,
+            render: props.renderer
+        };
+    }
 });
diff --git a/packages/app/src/utils/legacyPluginToReactComponent.ts b/packages/app/src/utils/legacyPluginToReactComponent.ts
index 3d93cc3915a..5da2e3f999c 100644
--- a/packages/app/src/utils/legacyPluginToReactComponent.ts
+++ b/packages/app/src/utils/legacyPluginToReactComponent.ts
@@ -1,16 +1,21 @@
 import React from "react";
 import { useRegisterLegacyPlugin } from "~/hooks/useRegisterLegacyPlugin";
 
-export interface LegacyPluginToReactComponentParams {
+export interface LegacyPluginToReactComponentParams<TProps extends Record<string, any>> {
     pluginType: string;
     componentDisplayName: string;
+    mapProps?: (props: TProps) => TProps;
 }
 
 export const legacyPluginToReactComponent = function <TProps extends Record<string, any>>(
-    params: LegacyPluginToReactComponentParams
+    params: LegacyPluginToReactComponentParams<TProps>
 ) {
     const Component: React.ComponentType<TProps> = props => {
-        useRegisterLegacyPlugin({ ...props, type: params.pluginType });
+        const plugin = Object.assign(
+            { type: params.pluginType },
+            params.mapProps ? params.mapProps(props) : props
+        );
+        useRegisterLegacyPlugin(plugin);
         return null;
     };
 

From 2e18a18c4600c16434bd72237c2d57f1da028a79 Mon Sep 17 00:00:00 2001
From: Adrian Smijulj <adrian1358@gmail.com>
Date: Fri, 13 Dec 2024 09:26:25 +0100
Subject: [PATCH 10/52] fix: improve import identifier uniqueness (#4437)

---
 .../cli-plugin-extensions/src/extensions/AdminExtension.ts | 7 +++----
 .../cli-plugin-extensions/src/extensions/ApiExtension.ts   | 7 ++++---
 .../src/extensions/PbElementExtension.ts                   | 7 +++----
 .../src/extensions/WebsiteExtension.ts                     | 7 +++----
 4 files changed, 13 insertions(+), 15 deletions(-)

diff --git a/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts b/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts
index 75136f5e471..87575cfb6f7 100644
--- a/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts
+++ b/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts
@@ -5,6 +5,7 @@ import chalk from "chalk";
 import { JsxFragment, Node, Project } from "ts-morph";
 import { formatCode } from "@webiny/cli-plugin-scaffold/utils";
 import { updateDependencies, updateWorkspaces } from "~/utils";
+import Case from "case";
 
 export class AdminExtension extends AbstractExtension {
     async link() {
@@ -38,12 +39,10 @@ export class AdminExtension extends AbstractExtension {
     }
 
     private async addPluginToAdminApp() {
-        const { name, packageName } = this.params;
-
         const extensionsFilePath = path.join("apps", "admin", "src", "Extensions.tsx");
 
-        const ucFirstName = name.charAt(0).toUpperCase() + name.slice(1);
-        const componentName = ucFirstName + "Extension";
+        const { packageName } = this.params;
+        const componentName = Case.pascal(packageName) + "Extension";
 
         const importName = "{ Extension as " + componentName + " }";
         const importPath = packageName;
diff --git a/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts b/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts
index 776374c84a8..35e6c269235 100644
--- a/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts
+++ b/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts
@@ -5,6 +5,7 @@ import chalk from "chalk";
 import { ArrayLiteralExpression, Node, Project } from "ts-morph";
 import { formatCode } from "@webiny/cli-plugin-scaffold/utils";
 import { updateDependencies, updateWorkspaces } from "~/utils";
+import Case from "case";
 
 export class ApiExtension extends AbstractExtension {
     async link() {
@@ -38,11 +39,11 @@ export class ApiExtension extends AbstractExtension {
     }
 
     private async addPluginToApiApp() {
-        const { name, packageName } = this.params;
-
         const extensionsFilePath = path.join("apps", "api", "graphql", "src", "extensions.ts");
 
-        const extensionFactory = name + "ExtensionFactory";
+        const { packageName } = this.params;
+        const extensionFactory = Case.pascal(packageName) + "ExtensionFactory";
+
         const importName = "{ createExtension as " + extensionFactory + " }";
         const importPath = packageName;
 
diff --git a/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts b/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts
index 9ca965948c5..0f7be7d90ce 100644
--- a/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts
+++ b/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts
@@ -5,6 +5,7 @@ import chalk from "chalk";
 import { JsxFragment, Node, Project } from "ts-morph";
 import { formatCode } from "@webiny/cli-plugin-scaffold/utils";
 import { updateDependencies, updateWorkspaces } from "~/utils";
+import Case from "case";
 
 export class PbElementExtension extends AbstractExtension {
     async link() {
@@ -46,12 +47,10 @@ export class PbElementExtension extends AbstractExtension {
     }
 
     private async addPluginToApp(app: "admin" | "website") {
-        const { name: extensionName, packageName } = this.params;
-
         const extensionsFilePath = path.join("apps", app, "src", "Extensions.tsx");
 
-        const ucFirstExtName = extensionName.charAt(0).toUpperCase() + extensionName.slice(1);
-        const componentName = ucFirstExtName + "Extension";
+        const { packageName } = this.params;
+        const componentName = Case.pascal(packageName) + "Extension";
 
         const importName = "{ Extension as " + componentName + " }";
         const importPath = packageName + "/src/" + app;
diff --git a/packages/cli-plugin-extensions/src/extensions/WebsiteExtension.ts b/packages/cli-plugin-extensions/src/extensions/WebsiteExtension.ts
index 42326e3bb91..875d29f010f 100644
--- a/packages/cli-plugin-extensions/src/extensions/WebsiteExtension.ts
+++ b/packages/cli-plugin-extensions/src/extensions/WebsiteExtension.ts
@@ -5,6 +5,7 @@ import chalk from "chalk";
 import { JsxFragment, Node, Project } from "ts-morph";
 import { formatCode } from "@webiny/cli-plugin-scaffold/utils";
 import { updateDependencies, updateWorkspaces } from "~/utils";
+import Case from "case";
 
 export class WebsiteExtension extends AbstractExtension {
     async link() {
@@ -38,12 +39,10 @@ export class WebsiteExtension extends AbstractExtension {
     }
 
     private async addPluginToWebsiteApp() {
-        const { name, packageName } = this.params;
-
         const extensionsFilePath = path.join("apps", "website", "src", "Extensions.tsx");
 
-        const ucFirstName = name.charAt(0).toUpperCase() + name.slice(1);
-        const componentName = ucFirstName + "Extension";
+        const { packageName } = this.params;
+        const componentName = Case.pascal(packageName) + "Extension";
 
         const importName = "{ Extension as " + componentName + " }";
         const importPath = packageName;

From 34b5ab0da4aed14c27d42defaa4d2c328a2034b3 Mon Sep 17 00:00:00 2001
From: Adrian Smijulj <adrian1358@gmail.com>
Date: Mon, 16 Dec 2024 10:51:16 +0100
Subject: [PATCH 11/52] fix: add error handling capabilities (#4447)

---
 .../src/hooks/useLoader.ts                    | 34 +++++++++++++------
 1 file changed, 23 insertions(+), 11 deletions(-)

diff --git a/packages/app-page-builder-elements/src/hooks/useLoader.ts b/packages/app-page-builder-elements/src/hooks/useLoader.ts
index 095b13e4bba..e96795b9941 100644
--- a/packages/app-page-builder-elements/src/hooks/useLoader.ts
+++ b/packages/app-page-builder-elements/src/hooks/useLoader.ts
@@ -2,21 +2,22 @@ import { useEffect, useMemo, useState, type DependencyList } from "react";
 import { createObjectHash } from "./useLoader/createObjectHash";
 import { useRenderer } from "..";
 
-export interface RendererLoader<TData = unknown> {
+export interface RendererLoader<TData = unknown, TError = unknown> {
     data: TData | null;
     loading: boolean;
     cacheHit: boolean;
     cacheKey: null | string;
+    error: null | TError;
 }
 
 export interface UseLoaderOptions {
     cacheKey?: DependencyList;
 }
 
-export function useLoader<TData = unknown>(
+export function useLoader<TData = unknown, TError = unknown>(
     loaderFn: () => Promise<TData>,
     options?: UseLoaderOptions
-): RendererLoader<TData> {
+): RendererLoader<TData, TError> {
     const { getElement, loaderCache } = useRenderer();
 
     const element = getElement();
@@ -29,15 +30,16 @@ export function useLoader<TData = unknown>(
         return loaderCache.read<TData>(cacheKey);
     }, [cacheKey]);
 
-    const [loader, setLoader] = useState<RendererLoader<TData>>(
+    const [loader, setLoader] = useState<RendererLoader<TData, TError>>(
         cachedData
             ? {
                   data: cachedData,
                   loading: false,
                   cacheHit: true,
-                  cacheKey
+                  cacheKey,
+                  error: null
               }
-            : { data: null, loading: true, cacheHit: false, cacheKey: null }
+            : { data: null, loading: true, cacheHit: false, cacheKey: null, error: null }
     );
 
     useEffect(() => {
@@ -46,15 +48,25 @@ export function useLoader<TData = unknown>(
         }
 
         if (cachedData) {
-            setLoader({ data: cachedData, loading: false, cacheKey, cacheHit: true });
+            setLoader({ data: cachedData, loading: false, cacheKey, cacheHit: true, error: null });
             return;
         }
 
-        setLoader({ data: loader.data, loading: true, cacheKey, cacheHit: false });
-        loaderFn().then(data => {
-            loaderCache.write(cacheKey, data);
-            setLoader({ data, loading: false, cacheKey, cacheHit: false });
+        setLoader({
+            data: loader.data,
+            error: loader.error,
+            loading: true,
+            cacheKey,
+            cacheHit: false
         });
+        loaderFn()
+            .then(data => {
+                loaderCache.write(cacheKey, data);
+                setLoader({ data, error: null, loading: false, cacheKey, cacheHit: false });
+            })
+            .catch(error => {
+                setLoader({ data: null, error, loading: false, cacheKey, cacheHit: false });
+            });
     }, [cacheKey]);
 
     return loader;

From 9c9318648bdbf0af91cc9b4bbf4594c9eff9733b Mon Sep 17 00:00:00 2001
From: adrians5j <adrian@webiny.com>
Date: Mon, 16 Dec 2024 10:57:05 +0100
Subject: [PATCH 12/52] fix: remove exports section

---
 .../cli-plugin-extensions/templates/pbElement/package.json    | 4 ----
 1 file changed, 4 deletions(-)

diff --git a/packages/cli-plugin-extensions/templates/pbElement/package.json b/packages/cli-plugin-extensions/templates/pbElement/package.json
index 016ab73ce41..5857ab2dc1f 100644
--- a/packages/cli-plugin-extensions/templates/pbElement/package.json
+++ b/packages/cli-plugin-extensions/templates/pbElement/package.json
@@ -1,9 +1,5 @@
 {
   "name": "PACKAGE_NAME",
-  "exports": {
-    "./admin": "./src/admin.tsx",
-    "./website": "./src/website.tsx"
-  },
   "version": "1.0.0",
   "keywords": [
     "webiny-extension",

From a9313d6fe58d640fb3b146a936a68f8d6ca8671d Mon Sep 17 00:00:00 2001
From: adrians5j <adrian@webiny.com>
Date: Mon, 16 Dec 2024 11:03:30 +0100
Subject: [PATCH 13/52] fix: export utils from `@webiny/app`

---
 packages/app-admin/src/index.ts | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/packages/app-admin/src/index.ts b/packages/app-admin/src/index.ts
index f1d1bf6f39e..56dfe4fed45 100644
--- a/packages/app-admin/src/index.ts
+++ b/packages/app-admin/src/index.ts
@@ -62,3 +62,11 @@ export { AaclPermission } from "@webiny/app-wcp/types";
 export { useTheme, ThemeProvider } from "@webiny/app-theme";
 
 export * from "@webiny/app/renderApp";
+
+// Exporting chosen utils from `@webiny/app` package.
+export * from "@webiny/app/utils/getApiUrl";
+export * from "@webiny/app/utils/getGqlApiUrl";
+export * from "@webiny/app/utils/getHeadlessCmsGqlApiUrl";
+export * from "@webiny/app/utils/getLocaleCode";
+export * from "@webiny/app/utils/getTenantId";
+export * from "@webiny/app/utils/isLocalhost";

From 017b55b5f13794fac397dbe6d6e987adcb940698 Mon Sep 17 00:00:00 2001
From: Adrian Smijulj <adrian1358@gmail.com>
Date: Tue, 17 Dec 2024 08:46:07 +0100
Subject: [PATCH 14/52] fix: create settings entry when new locale is created
 (#4446)

---
 .../__tests__/graphql/i18n.ts                 |  15 ++
 .../__tests__/settings.test.ts                |  40 ++++-
 .../__tests__/useGqlHandler.ts                |  22 +--
 .../src/plugins/crud/index.ts                 | 167 ++++++++++--------
 packages/api-i18n/src/graphql/context.ts      |  22 ++-
 packages/api-i18n/src/types.ts                |   4 +
 6 files changed, 180 insertions(+), 90 deletions(-)
 create mode 100644 packages/api-form-builder/__tests__/graphql/i18n.ts

diff --git a/packages/api-form-builder/__tests__/graphql/i18n.ts b/packages/api-form-builder/__tests__/graphql/i18n.ts
new file mode 100644
index 00000000000..f33ffc38db5
--- /dev/null
+++ b/packages/api-form-builder/__tests__/graphql/i18n.ts
@@ -0,0 +1,15 @@
+export const CREATE_LOCALE = /* GraphQL */ `
+    mutation CreateI18NLocale($data: I18NLocaleInput!) {
+        i18n {
+            createI18NLocale(data: $data) {
+                data {
+                    code
+                }
+                error {
+                    message
+                    code
+                }
+            }
+        }
+    }
+`;
diff --git a/packages/api-form-builder/__tests__/settings.test.ts b/packages/api-form-builder/__tests__/settings.test.ts
index 764543af83c..48a100cc22d 100644
--- a/packages/api-form-builder/__tests__/settings.test.ts
+++ b/packages/api-form-builder/__tests__/settings.test.ts
@@ -1,7 +1,8 @@
 import useGqlHandler from "./useGqlHandler";
+import { GET_SETTINGS } from "~tests/graphql/formBuilderSettings";
 
 describe("Settings Test", () => {
-    const { getSettings, updateSettings, install, isInstalled } = useGqlHandler();
+    const { getSettings, updateSettings, install, createI18NLocale, isInstalled } = useGqlHandler();
 
     test(`Should not be able to get & update settings before "install"`, async () => {
         // Should not have any settings without install
@@ -154,4 +155,41 @@ describe("Settings Test", () => {
             }
         });
     });
+
+    test(`Should be able to get & update settings after in a new locale`, async () => {
+        // Let's install the `Form builder`
+        await install({ domain: "http://localhost:3001" });
+
+        await createI18NLocale({ data: { code: "de-DE" } });
+
+        const { invoke } = useGqlHandler();
+
+        // Had to do it via `invoke` directly because this way it's possible to
+        // set the locale header. Wasn't easily possible via the `getSettings` helper.
+        const [newLocaleFbSettings] = await invoke({
+            body: { query: GET_SETTINGS },
+            headers: {
+                "x-i18n-locale": "default:de-DE;content:de-DE;"
+            }
+        });
+
+        // Settings should exist in the newly created locale.
+        expect(newLocaleFbSettings).toEqual({
+            data: {
+                formBuilder: {
+                    getSettings: {
+                        data: {
+                            domain: null,
+                            reCaptcha: {
+                                enabled: null,
+                                secretKey: null,
+                                siteKey: null
+                            }
+                        },
+                        error: null
+                    }
+                }
+            }
+        });
+    });
 });
diff --git a/packages/api-form-builder/__tests__/useGqlHandler.ts b/packages/api-form-builder/__tests__/useGqlHandler.ts
index 0d6a8108469..e5fc524dd47 100644
--- a/packages/api-form-builder/__tests__/useGqlHandler.ts
+++ b/packages/api-form-builder/__tests__/useGqlHandler.ts
@@ -8,8 +8,12 @@ import i18nContext from "@webiny/api-i18n/graphql/context";
 import { mockLocalesPlugins } from "@webiny/api-i18n/graphql/testing";
 import { SecurityIdentity, SecurityPermission } from "@webiny/api-security/types";
 import { createFormBuilder } from "~/index";
+import { createI18NGraphQL } from "@webiny/api-i18n/graphql";
+
 // Graphql
 import { INSTALL as INSTALL_FILE_MANAGER } from "./graphql/fileManagerSettings";
+import { CREATE_LOCALE } from "./graphql/i18n";
+
 import {
     GET_SETTINGS,
     INSTALL,
@@ -41,11 +45,7 @@ import { PluginCollection } from "@webiny/plugins/types";
 import { getStorageOps } from "@webiny/project-utils/testing/environment";
 import { FileManagerStorageOperations } from "@webiny/api-file-manager/types";
 import { HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types";
-import {
-    CmsParametersPlugin,
-    createHeadlessCmsContext,
-    createHeadlessCmsGraphQL
-} from "@webiny/api-headless-cms";
+import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms";
 import { FormBuilderStorageOperations } from "~/types";
 import { APIGatewayEvent, LambdaContext } from "@webiny/handler-aws/types";
 import { createPageBuilderContext } from "@webiny/api-page-builder";
@@ -83,14 +83,9 @@ export default (params: UseGqlHandlerParams = {}) => {
             graphqlHandlerPlugins(),
             ...createTenancyAndSecurity({ permissions, identity }),
             i18nContext(),
+            createI18NGraphQL(),
             i18nStorage.storageOperations,
             mockLocalesPlugins(),
-            new CmsParametersPlugin(async () => {
-                return {
-                    locale: "en-US",
-                    type: "manage"
-                };
-            }),
             createHeadlessCmsContext({ storageOperations: cmsStorage.storageOperations }),
             createHeadlessCmsGraphQL(),
             createPageBuilderContext({
@@ -228,6 +223,11 @@ export default (params: UseGqlHandlerParams = {}) => {
         },
         async exportFormSubmissions(variables: Record<string, any>) {
             return invoke({ body: { query: EXPORT_FORM_SUBMISSIONS, variables } });
+        },
+
+        // Locales.
+        async createI18NLocale(variables: Record<string, any>) {
+            return invoke({ body: { query: CREATE_LOCALE, variables } });
         }
     };
 };
diff --git a/packages/api-form-builder/src/plugins/crud/index.ts b/packages/api-form-builder/src/plugins/crud/index.ts
index a4df2a2e981..5a20f2300b6 100644
--- a/packages/api-form-builder/src/plugins/crud/index.ts
+++ b/packages/api-form-builder/src/plugins/crud/index.ts
@@ -15,94 +15,107 @@ export interface CreateFormBuilderCrudParams {
 export default (params: CreateFormBuilderCrudParams) => {
     const { storageOperations } = params;
 
-    return new ContextPlugin<FormBuilderContext>(async context => {
-        const getLocale = () => {
-            const locale = context.i18n.getContentLocale();
-            if (!locale) {
-                throw new WebinyError(
-                    "Missing locale on context.i18n locale in API Form Builder.",
-                    "LOCALE_ERROR"
-                );
+    return [
+        new ContextPlugin<FormBuilderContext>(async context => {
+            const getLocale = () => {
+                const locale = context.i18n.getContentLocale();
+                if (!locale) {
+                    throw new WebinyError(
+                        "Missing locale on context.i18n locale in API Form Builder.",
+                        "LOCALE_ERROR"
+                    );
+                }
+                return locale;
+            };
+
+            const getIdentity = () => {
+                return context.security.getIdentity();
+            };
+
+            const getTenant = () => {
+                return context.tenancy.getCurrentTenant();
+            };
+
+            if (storageOperations.beforeInit) {
+                try {
+                    await storageOperations.beforeInit(context);
+                } catch (ex) {
+                    throw new WebinyError(
+                        ex.message ||
+                            "Could not run before init in Form Builder storage operations.",
+                        ex.code || "STORAGE_OPERATIONS_BEFORE_INIT_ERROR",
+                        {
+                            ...ex
+                        }
+                    );
+                }
             }
-            return locale;
-        };
 
-        const getIdentity = () => {
-            return context.security.getIdentity();
-        };
+            const basePermissionsArgs = {
+                getIdentity,
+                fullAccessPermissionName: "fb.*"
+            };
+
+            const formsPermissions = new FormsPermissions({
+                ...basePermissionsArgs,
+                getPermissions: () => context.security.getPermissions("fb.form")
+            });
 
-        const getTenant = () => {
-            return context.tenancy.getCurrentTenant();
-        };
+            const settingsPermissions = new SettingsPermissions({
+                ...basePermissionsArgs,
+                getPermissions: () => context.security.getPermissions("fb.settings")
+            });
 
-        if (storageOperations.beforeInit) {
+            context.formBuilder = {
+                storageOperations,
+                ...createSystemCrud({
+                    getIdentity,
+                    getTenant,
+                    getLocale,
+                    context
+                }),
+                ...createSettingsCrud({
+                    getTenant,
+                    getLocale,
+                    settingsPermissions,
+                    context
+                }),
+                ...createFormsCrud({
+                    getTenant,
+                    getLocale,
+                    formsPermissions,
+                    context
+                }),
+                ...createSubmissionsCrud({
+                    context,
+                    formsPermissions
+                })
+            };
+
+            if (!storageOperations.init) {
+                return;
+            }
             try {
-                await storageOperations.beforeInit(context);
+                await storageOperations.init(context);
             } catch (ex) {
                 throw new WebinyError(
-                    ex.message || "Could not run before init in Form Builder storage operations.",
-                    ex.code || "STORAGE_OPERATIONS_BEFORE_INIT_ERROR",
+                    ex.message || "Could not run init in Form Builder storage operations.",
+                    ex.code || "STORAGE_OPERATIONS_INIT_ERROR",
                     {
                         ...ex
                     }
                 );
             }
-        }
-
-        const basePermissionsArgs = {
-            getIdentity,
-            fullAccessPermissionName: "fb.*"
-        };
-
-        const formsPermissions = new FormsPermissions({
-            ...basePermissionsArgs,
-            getPermissions: () => context.security.getPermissions("fb.form")
-        });
+        }),
 
-        const settingsPermissions = new SettingsPermissions({
-            ...basePermissionsArgs,
-            getPermissions: () => context.security.getPermissions("fb.settings")
-        });
-
-        context.formBuilder = {
-            storageOperations,
-            ...createSystemCrud({
-                getIdentity,
-                getTenant,
-                getLocale,
-                context
-            }),
-            ...createSettingsCrud({
-                getTenant,
-                getLocale,
-                settingsPermissions,
-                context
-            }),
-            ...createFormsCrud({
-                getTenant,
-                getLocale,
-                formsPermissions,
-                context
-            }),
-            ...createSubmissionsCrud({
-                context,
-                formsPermissions
-            })
-        };
-
-        if (!storageOperations.init) {
-            return;
-        }
-        try {
-            await storageOperations.init(context);
-        } catch (ex) {
-            throw new WebinyError(
-                ex.message || "Could not run init in Form Builder storage operations.",
-                ex.code || "STORAGE_OPERATIONS_INIT_ERROR",
-                {
-                    ...ex
-                }
-            );
-        }
-    });
+        // Once a new locale is created, we need to create a new settings entry for it.
+        new ContextPlugin<FormBuilderContext>(async context => {
+            context.i18n.locales.onLocaleAfterCreate.subscribe(async params => {
+                const { locale } = params;
+                await context.i18n.withLocale(locale, async () => {
+                    return context.formBuilder.createSettings({});
+                });
+            });
+        })
+    ];
 };
diff --git a/packages/api-i18n/src/graphql/context.ts b/packages/api-i18n/src/graphql/context.ts
index 4aaa948073a..9293ea3af4b 100644
--- a/packages/api-i18n/src/graphql/context.ts
+++ b/packages/api-i18n/src/graphql/context.ts
@@ -239,6 +239,25 @@ const createBaseContextPlugin = () => {
             return results;
         };
 
+        const withLocale: I18NContextObject["withLocale"] = async (locale, cb) => {
+            const initialLocale = getDefaultLocale();
+            if (!initialLocale) {
+                return;
+            }
+
+            setContentLocale(locale);
+            setCurrentLocale("default", locale);
+
+            try {
+                // We have to await the callback, because, in case it's an async function,
+                // the `finally` block would get executed before the callback finishes.
+                return await cb();
+            } finally {
+                setContentLocale(initialLocale);
+                setCurrentLocale("default", initialLocale);
+            }
+        };
+
         context.i18n = {
             ...context.i18n,
             getDefaultLocale,
@@ -252,7 +271,8 @@ const createBaseContextPlugin = () => {
             reloadLocales,
             hasI18NContentPermission: () => hasI18NContentPermission(context),
             checkI18NContentPermission,
-            withEachLocale
+            withEachLocale,
+            withLocale
         };
     });
 };
diff --git a/packages/api-i18n/src/types.ts b/packages/api-i18n/src/types.ts
index f0b83d43d04..407d6aea0bb 100644
--- a/packages/api-i18n/src/types.ts
+++ b/packages/api-i18n/src/types.ts
@@ -44,6 +44,10 @@ export interface I18NContextObject {
         locales: I18NLocale[],
         cb: (locale: I18NLocale) => Promise<TReturn>
     ) => Promise<TReturn[] | undefined>;
+    withLocale: <TReturn>(
+        locale: I18NLocale,
+        cb: () => Promise<TReturn>
+    ) => Promise<TReturn | undefined>;
 }
 
 export interface SystemInstallParams {

From f2b573cf2644fb5b8bdc3e7f315c8bcdf7e532ab Mon Sep 17 00:00:00 2001
From: Adrian Smijulj <adrian1358@gmail.com>
Date: Tue, 17 Dec 2024 17:03:49 +0100
Subject: [PATCH 15/52] fix: preload fonts (#4450)

---
 .../handlers/render/linkPreloading.test.ts    |   8 +-
 .../render/handlers/render/renderUrl.test.ts  |   8 +-
 .../src/render/defaultRenderUrlFunction.ts    | 157 ++++++++++++++++
 .../src/render/preloadCss.ts                  |   7 +
 .../src/render/preloadFonts.ts                |  37 ++++
 .../src/render/preloadJs.ts                   |   7 +
 .../src/render/renderUrl.ts                   | 169 +-----------------
 .../src/render/types.ts                       |   5 +
 8 files changed, 233 insertions(+), 165 deletions(-)
 create mode 100644 packages/api-prerendering-service/src/render/defaultRenderUrlFunction.ts
 create mode 100644 packages/api-prerendering-service/src/render/preloadCss.ts
 create mode 100644 packages/api-prerendering-service/src/render/preloadFonts.ts
 create mode 100644 packages/api-prerendering-service/src/render/preloadJs.ts

diff --git a/packages/api-prerendering-service/__tests__/render/handlers/render/linkPreloading.test.ts b/packages/api-prerendering-service/__tests__/render/handlers/render/linkPreloading.test.ts
index 9ce663a69fe..88c106c5071 100644
--- a/packages/api-prerendering-service/__tests__/render/handlers/render/linkPreloading.test.ts
+++ b/packages/api-prerendering-service/__tests__/render/handlers/render/linkPreloading.test.ts
@@ -15,7 +15,9 @@ describe(`"renderUrl" Function Test`, () => {
             renderUrlFunction: async () => {
                 return {
                     content: BASE_HTML,
-                    meta: {}
+                    meta: {
+                        interceptedRequests: []
+                    }
                 };
             }
         });
@@ -58,7 +60,9 @@ describe(`"renderUrl" Function Test`, () => {
             renderUrlFunction: async () => {
                 return {
                     content: BASE_HTML,
-                    meta: {}
+                    meta: {
+                        interceptedRequests: []
+                    }
                 };
             }
         });
diff --git a/packages/api-prerendering-service/__tests__/render/handlers/render/renderUrl.test.ts b/packages/api-prerendering-service/__tests__/render/handlers/render/renderUrl.test.ts
index 9ce663a69fe..88c106c5071 100644
--- a/packages/api-prerendering-service/__tests__/render/handlers/render/renderUrl.test.ts
+++ b/packages/api-prerendering-service/__tests__/render/handlers/render/renderUrl.test.ts
@@ -15,7 +15,9 @@ describe(`"renderUrl" Function Test`, () => {
             renderUrlFunction: async () => {
                 return {
                     content: BASE_HTML,
-                    meta: {}
+                    meta: {
+                        interceptedRequests: []
+                    }
                 };
             }
         });
@@ -58,7 +60,9 @@ describe(`"renderUrl" Function Test`, () => {
             renderUrlFunction: async () => {
                 return {
                     content: BASE_HTML,
-                    meta: {}
+                    meta: {
+                        interceptedRequests: []
+                    }
                 };
             }
         });
diff --git a/packages/api-prerendering-service/src/render/defaultRenderUrlFunction.ts b/packages/api-prerendering-service/src/render/defaultRenderUrlFunction.ts
new file mode 100644
index 00000000000..6bbddfa5b9b
--- /dev/null
+++ b/packages/api-prerendering-service/src/render/defaultRenderUrlFunction.ts
@@ -0,0 +1,157 @@
+import chromium from "@sparticuz/chromium";
+import puppeteer, { Browser, Page } from "puppeteer-core";
+import extractPeLoaderDataFromHtml from "./extractPeLoaderDataFromHtml";
+import { RenderResult, RenderUrlCallableParams } from "./types";
+import { TagPathLink } from "~/types";
+
+const windowSet = (page: Page, name: string, value: string | boolean) => {
+    page.evaluateOnNewDocument(`
+    Object.defineProperty(window, '${name}', {
+      get() {
+        return '${value}'
+      }
+    })`);
+};
+
+export interface File {
+    type: string;
+    body: any;
+    name: string;
+    meta: {
+        tags?: TagPathLink[];
+        [key: string]: any;
+    };
+}
+
+export const defaultRenderUrlFunction = async (
+    url: string,
+    params: RenderUrlCallableParams
+): Promise<RenderResult> => {
+    let browser!: Browser;
+
+    try {
+        browser = await puppeteer.launch({
+            args: chromium.args,
+            defaultViewport: chromium.defaultViewport,
+            executablePath: await chromium.executablePath(),
+            headless: chromium.headless,
+            ignoreHTTPSErrors: true
+        });
+
+        const browserPage = await browser.newPage();
+
+        // Can be used to add additional logic - e.g. skip a GraphQL query to be made when in pre-rendering process.
+        windowSet(browserPage, "__PS_RENDER__", true);
+
+        const tenant = params.args.tenant;
+        if (tenant) {
+            console.log("Setting tenant (__PS_RENDER_TENANT__) to window object....");
+            windowSet(browserPage, "__PS_RENDER_TENANT__", tenant);
+        }
+
+        const locale = params.args.locale;
+        if (locale) {
+            console.log("Setting locale (__PS_RENDER_LOCALE__) to window object....");
+            windowSet(browserPage, "__PS_RENDER_LOCALE__", locale);
+        }
+
+        const renderResult: RenderResult = {
+            content: "",
+            meta: {
+                interceptedRequests: [],
+                apolloState: {},
+                cachedData: {
+                    apolloGraphQl: [],
+                    peLoaders: []
+                }
+            }
+        };
+
+        // Don't load these resources during prerender.
+        const skipResources = ["image"];
+        await browserPage.setRequestInterception(true);
+
+        browserPage.on("request", request => {
+            const issuedRequest = {
+                type: request.resourceType(),
+                url: request.url(),
+                aborted: false
+            };
+
+            if (skipResources.includes(issuedRequest.type)) {
+                issuedRequest.aborted = true;
+                request.abort();
+            } else {
+                request.continue();
+            }
+
+            renderResult.meta.interceptedRequests.push(issuedRequest);
+        });
+
+        // TODO: should be a plugin.
+        browserPage.on("response", async response => {
+            const request = response.request();
+            const url = request.url();
+            if (url.includes("/graphql") && request.method() === "POST") {
+                const responses = (await response.json()) as Record<string, any>;
+                const postData = JSON.parse(request.postData() as string);
+                const operations = Array.isArray(postData) ? postData : [postData];
+
+                for (let i = 0; i < operations.length; i++) {
+                    const { query, variables } = operations[i];
+
+                    // For now, we're doing a basic @ps(cache: true) match to determine if the
+                    // cache was set true. In the future, if we start introducing additional
+                    // parameters here, we should probably make this parsing smarter.
+                    const mustCache = query.match(/@ps\((cache: true)\)/);
+
+                    if (mustCache) {
+                        const data = Array.isArray(responses) ? responses[i].data : responses.data;
+                        renderResult.meta.cachedData.apolloGraphQl.push({
+                            query,
+                            variables,
+                            data
+                        });
+                    }
+                }
+                return;
+            }
+        });
+
+        // Load URL and wait for all network requests to settle.
+        await browserPage.goto(url, { waitUntil: "networkidle0" });
+
+        renderResult.content = await browserPage.content();
+
+        renderResult.meta.apolloState = await browserPage.evaluate(() => {
+            // @ts-expect-error
+            return window.getApolloState();
+        });
+
+        renderResult.meta.cachedData.peLoaders = extractPeLoaderDataFromHtml(renderResult.content);
+
+        return renderResult;
+    } finally {
+        if (browser) {
+            // We need to close all open pages first, to prevent browser from hanging when closed.
+            const pages = await browser.pages();
+            for (const page of pages) {
+                await page.close();
+            }
+
+            // This is fixing an issue where the `await browser.close()` would hang indefinitely.
+            // The "inspiration" for this fix came from the following issue:
+            // https://github.com/Sparticuz/chromium/issues/85
+            console.log("Killing browser process...");
+            const childProcess = browser.process();
+            if (childProcess) {
+                childProcess.kill(9);
+            }
+
+            console.log("Browser process killed.");
+        }
+    }
+
+    // There's no catch block here because errors are already being handled
+    // in the entrypoint function, located in `./index.ts` file.
+};
diff --git a/packages/api-prerendering-service/src/render/preloadCss.ts b/packages/api-prerendering-service/src/render/preloadCss.ts
new file mode 100644
index 00000000000..805967db6c7
--- /dev/null
+++ b/packages/api-prerendering-service/src/render/preloadCss.ts
@@ -0,0 +1,7 @@
+import { RenderResult } from "./types";
+
+export const preloadCss = (render: RenderResult): void => {
+    const regex = /<link href="\/static\/css\//gm;
+    const subst = `<link data-link-preload data-link-preload-type="markup" href="/https/github.com/static/css/`;
+    render.content = render.content.replace(regex, subst);
+};
diff --git a/packages/api-prerendering-service/src/render/preloadFonts.ts b/packages/api-prerendering-service/src/render/preloadFonts.ts
new file mode 100644
index 00000000000..e177403a985
--- /dev/null
+++ b/packages/api-prerendering-service/src/render/preloadFonts.ts
@@ -0,0 +1,37 @@
+import { RenderResult } from "~/render/types";
+
+function getFontType(url: string) {
+    if (url.endsWith(".woff2")) {
+        return "woff2";
+    }
+    if (url.endsWith(".woff")) {
+        return "woff";
+    }
+    if (url.endsWith(".ttf")) {
+        return "truetype";
+    }
+    if (url.endsWith(".otf")) {
+        return "opentype";
+    }
+    if (url.endsWith(".eot")) {
+        return "embedded-opentype";
+    }
+    return "font";
+}
+
+export const preloadFonts = (render: RenderResult): void => {
+    const fontsRequests = render.meta.interceptedRequests.filter(
+        req => req.type === "font" && req.url
+    );
+
+    const preloadLinks: string = Array.from(fontsRequests)
+        .map(req => {
+            return `<link rel="preload" href="${req.url}" as="font" type="font/${getFontType(
+                req.url
+            )}" crossorigin="anonymous">`;
+        })
+        .join("\n");
+
+    // Inject the preload tags into the <head> section
+    render.content = render.content.replace("</head>", `${preloadLinks}</head>`);
+};
diff --git a/packages/api-prerendering-service/src/render/preloadJs.ts b/packages/api-prerendering-service/src/render/preloadJs.ts
new file mode 100644
index 00000000000..63fae20d827
--- /dev/null
+++ b/packages/api-prerendering-service/src/render/preloadJs.ts
@@ -0,0 +1,7 @@
+import { RenderResult } from "~/render/types";
+
+export const preloadJs = (render: RenderResult): void => {
+    const regex = /<script (src="\/static\/js\/)/gm;
+    const subst = `<script data-link-preload data-link-preload-type="markup" src="/https/github.com/static/js/`;
+    render.content = render.content.replace(regex, subst);
+};
diff --git a/packages/api-prerendering-service/src/render/renderUrl.ts b/packages/api-prerendering-service/src/render/renderUrl.ts
index c3333b71c5f..a82acdc8c58 100644
--- a/packages/api-prerendering-service/src/render/renderUrl.ts
+++ b/packages/api-prerendering-service/src/render/renderUrl.ts
@@ -1,5 +1,3 @@
-import chromium from "@sparticuz/chromium";
-import puppeteer, { Browser, Page } from "puppeteer-core";
 import posthtml from "posthtml";
 import { noopener } from "posthtml-noopener";
 /**
@@ -14,26 +12,13 @@ import injectRenderTs from "./injectRenderTs";
 import injectTenantLocale from "./injectTenantLocale";
 import injectNotFoundPageFlag from "./injectNotFoundPageFlag";
 import getPsTags from "./getPsTags";
-import extractPeLoaderDataFromHtml from "./extractPeLoaderDataFromHtml";
+import { defaultRenderUrlFunction } from "./defaultRenderUrlFunction";
 import shortid from "shortid";
-import {
-    GraphQLCacheEntry,
-    PeLoaderCacheEntry,
-    RenderResult,
-    RenderUrlCallableParams,
-    RenderUrlParams,
-    RenderUrlPostHtmlParams
-} from "./types";
+import { RenderResult, RenderUrlParams, RenderUrlPostHtmlParams } from "./types";
 import { TagPathLink } from "~/types";
-
-const windowSet = (page: Page, name: string, value: string | boolean) => {
-    page.evaluateOnNewDocument(`
-    Object.defineProperty(window, '${name}', {
-      get() {
-        return '${value}'
-      }
-    })`);
-};
+import { preloadJs } from "~/render/preloadJs";
+import { preloadCss } from "~/render/preloadCss";
+import { preloadFonts } from "~/render/preloadFonts";
 
 interface Meta {
     path: string;
@@ -66,22 +51,11 @@ export default async (url: string, args: RenderUrlParams): Promise<[File[], Meta
     const render = await renderUrl(url, args);
 
     // Process HTML.
-    // TODO: should be plugins (will also eliminate lower ts-ignore instructions).
     console.log("Processing HTML...");
 
-    // TODO: regular text processing plugins...
-
-    {
-        const regex = /<script (src="\/static\/js\/)/gm;
-        const subst = `<script data-link-preload data-link-preload-type="markup" src="/https/github.com/static/js/`;
-        render.content = render.content.replace(regex, subst);
-    }
-
-    {
-        const regex = /<link href="\/static\/css\//gm;
-        const subst = `<link data-link-preload data-link-preload-type="markup" href="/https/github.com/static/css/`;
-        render.content = render.content.replace(regex, subst);
-    }
+    preloadJs(render);
+    preloadCss(render);
+    preloadFonts(render);
 
     const allArgs: RenderUrlPostHtmlParams = { render, args, path: args.args.path, id, ts };
     const { html } = await posthtml([
@@ -120,130 +94,3 @@ export default async (url: string, args: RenderUrlParams): Promise<[File[], Meta
         allArgs
     ];
 };
-
-export const defaultRenderUrlFunction = async (
-    url: string,
-    params: RenderUrlCallableParams
-): Promise<RenderResult> => {
-    let browser!: Browser;
-
-    try {
-        browser = await puppeteer.launch({
-            args: chromium.args,
-            defaultViewport: chromium.defaultViewport,
-            executablePath: await chromium.executablePath(),
-            headless: chromium.headless,
-            ignoreHTTPSErrors: true
-        });
-
-        const browserPage = await browser.newPage();
-
-        // Can be used to add additional logic - e.g. skip a GraphQL query to be made when in pre-rendering process.
-        windowSet(browserPage, "__PS_RENDER__", true);
-
-        const tenant = params.args.tenant;
-        if (tenant) {
-            console.log("Setting tenant (__PS_RENDER_TENANT__) to window object....");
-            windowSet(browserPage, "__PS_RENDER_TENANT__", tenant);
-        }
-
-        const locale = params.args.locale;
-        if (locale) {
-            console.log("Setting locale (__PS_RENDER_LOCALE__) to window object....");
-            windowSet(browserPage, "__PS_RENDER_LOCALE__", locale);
-        }
-
-        // Don't load these resources during prerender.
-        const skipResources = ["image", "stylesheet"];
-        await browserPage.setRequestInterception(true);
-
-        browserPage.on("request", request => {
-            if (skipResources.includes(request.resourceType())) {
-                request.abort();
-            } else {
-                request.continue();
-            }
-        });
-
-        const cachedData: {
-            apolloGraphQl: GraphQLCacheEntry[];
-            peLoaders: PeLoaderCacheEntry[];
-        } = {
-            apolloGraphQl: [],
-            peLoaders: []
-        };
-
-        // TODO: should be a plugin.
-        browserPage.on("response", async response => {
-            const request = response.request();
-            const url = request.url();
-            if (url.includes("/graphql") && request.method() === "POST") {
-                const responses = (await response.json()) as Record<string, any>;
-                const postData = JSON.parse(request.postData() as string);
-                const operations = Array.isArray(postData) ? postData : [postData];
-
-                for (let i = 0; i < operations.length; i++) {
-                    const { query, variables } = operations[i];
-
-                    // For now, we're doing a basic @ps(cache: true) match to determine if the
-                    // cache was set true. In the future, if we start introducing additional
-                    // parameters here, we should probably make this parsing smarter.
-                    const mustCache = query.match(/@ps\((cache: true)\)/);
-
-                    if (mustCache) {
-                        const data = Array.isArray(responses) ? responses[i].data : responses.data;
-                        cachedData.apolloGraphQl.push({
-                            query,
-                            variables,
-                            data
-                        });
-                    }
-                }
-                return;
-            }
-        });
-
-        // Load URL and wait for all network requests to settle.
-        await browserPage.goto(url, { waitUntil: "networkidle0" });
-
-        const apolloState = await browserPage.evaluate(() => {
-            // @ts-expect-error
-            return window.getApolloState();
-        });
-
-        const content = await browserPage.content();
-
-        cachedData.peLoaders = extractPeLoaderDataFromHtml(content);
-
-        return {
-            content,
-            // TODO: ideally, meta should be assigned here in a more "plugins style" way, not hardcoded.
-            meta: {
-                cachedData,
-                apolloState
-            }
-        };
-    } finally {
-        if (browser) {
-            // We need to close all open pages first, to prevent browser from hanging when closed.
-            const pages = await browser.pages();
-            for (const page of pages) {
-                await page.close();
-            }
-
-            // This is fixing an issue where the `await browser.close()` would hang indefinitely.
-            // The "inspiration" for this fix came from the following issue:
-            // https://github.com/Sparticuz/chromium/issues/85
-            console.log("Killing browser process...");
-            const childProcess = browser.process();
-            if (childProcess) {
-                childProcess.kill(9);
-            }
-
-            console.log("Browser process killed.");
-        }
-    }
-
-    // There's no catch block here because errors are already being handled
-    // in the entrypoint function, located in `./index.ts` file.
-};
diff --git a/packages/api-prerendering-service/src/render/types.ts b/packages/api-prerendering-service/src/render/types.ts
index d92a917d6f0..527c8d14a57 100644
--- a/packages/api-prerendering-service/src/render/types.ts
+++ b/packages/api-prerendering-service/src/render/types.ts
@@ -20,6 +20,7 @@ export interface RenderHookPlugin extends Plugin {
 export interface RenderApolloState {
     [key: string]: string;
 }
+
 /**
  * @internal
  */
@@ -38,6 +39,7 @@ export interface PeLoaderCacheEntry {
 export interface RenderResult {
     content: string;
     meta: {
+        interceptedRequests: Array<{ type: string; url: string }>;
         apolloState: RenderApolloState;
         cachedData: {
             apolloGraphQl: GraphQLCacheEntry[];
@@ -47,6 +49,7 @@ export interface RenderResult {
         [key: string]: any;
     };
 }
+
 /**
  * @internal
  */
@@ -54,12 +57,14 @@ export interface RenderUrlCallableParams {
     context: Context;
     args: RenderEvent;
 }
+
 /**
  * @internal
  */
 export interface RenderUrlParams extends RenderUrlCallableParams {
     renderUrlFunction?: (url: string, params: RenderUrlCallableParams) => Promise<RenderResult>;
 }
+
 /**
  * @internal
  */

From edaf911c8ba285f73efacabbc98c2f784a2a1a80 Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Wed, 18 Dec 2024 10:45:13 +0100
Subject: [PATCH 16/52] fix(app-headless-cms): add delete prompt to objects and
 dynamic zones (#4428)

---
 .../dynamicZone/MultiValueDynamicZone.tsx     | 15 ++++++++++++++-
 .../dynamicZone/SingleValueDynamicZone.tsx    | 11 ++++++++++-
 .../fieldRenderers/object/multipleObjects.tsx | 19 ++++++++++++++++++-
 .../object/multipleObjectsAccordion.tsx       | 19 ++++++++++++++++++-
 4 files changed, 60 insertions(+), 4 deletions(-)

diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx
index ea6efceeead..44d6abacb7d 100644
--- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx
+++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx
@@ -22,6 +22,7 @@ import {
 import { makeDecoratable } from "@webiny/react-composition";
 import { TemplateProvider } from "~/admin/plugins/fieldRenderers/dynamicZone/TemplateProvider";
 import { ParentValueIndexProvider } from "~/admin/components/ModelFieldProvider";
+import { useConfirmationDialog } from "@webiny/app-admin";
 
 const BottomMargin = styled.div`
     margin-bottom: 20px;
@@ -189,6 +190,12 @@ interface MultiValueDynamicZoneProps {
 }
 
 export const MultiValueDynamicZone = (props: MultiValueDynamicZoneProps) => {
+    const { showConfirmation } = useConfirmationDialog({
+        message: `Are you sure you want to delete this item? This action is not reversible.`,
+        acceptLabel: `Yes, I'm sure!`,
+        cancelLabel: `No, leave it.`
+    });
+
     const { bind, getBind, contentModel } = props;
     const onTemplate = (template: CmsDynamicZoneTemplateWithTypename) => {
         bind.appendValue({ _templateId: template.id, __typename: template.__typename });
@@ -211,6 +218,12 @@ export const MultiValueDynamicZone = (props: MultiValueDynamicZoneProps) => {
                         {values.map((value, index) => {
                             const Bind = getBind(index);
 
+                            const onDelete = () => {
+                                showConfirmation(() => {
+                                    bind.removeValue(index);
+                                });
+                            };
+
                             return (
                                 <ParentValueIndexProvider key={index} index={index}>
                                     <TemplateValueForm
@@ -221,7 +234,7 @@ export const MultiValueDynamicZone = (props: MultiValueDynamicZoneProps) => {
                                         isLast={index === values.length - 1}
                                         onMoveUp={() => bind.moveValueUp(index)}
                                         onMoveDown={() => bind.moveValueDown(index)}
-                                        onDelete={() => bind.removeValue(index)}
+                                        onDelete={onDelete}
                                         onClone={value => cloneValue(value, index)}
                                     />
                                 </ParentValueIndexProvider>
diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx
index aa7450755f8..aabf0891b7f 100644
--- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx
+++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx
@@ -18,6 +18,7 @@ import {
     ParentValueIndexProvider,
     ModelFieldProvider
 } from "~/admin/components/ModelFieldProvider";
+import { useConfirmationDialog } from "@webiny/app-admin";
 
 type GetBind = CmsModelFieldRendererProps["getBind"];
 
@@ -34,6 +35,12 @@ export const SingleValueDynamicZone = ({
     contentModel,
     getBind
 }: SingleValueDynamicZoneProps) => {
+    const { showConfirmation } = useConfirmationDialog({
+        message: `Are you sure you want to remove this item? This action is not reversible.`,
+        acceptLabel: `Yes, I'm sure!`,
+        cancelLabel: `No, leave it.`
+    });
+
     const onTemplate = (template: CmsDynamicZoneTemplateWithTypename) => {
         bind.onChange({ _templateId: template.id, __typename: template.__typename });
     };
@@ -47,7 +54,9 @@ export const SingleValueDynamicZone = ({
     const Bind = getBind();
 
     const unsetValue = () => {
-        bind.onChange(null);
+        showConfirmation(() => {
+            bind.onChange(null);
+        });
     };
 
     return (
diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjects.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjects.tsx
index 8d09900a8da..4e33f8cc59e 100644
--- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjects.tsx
+++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjects.tsx
@@ -22,6 +22,7 @@ import {
 } from "./StyledComponents";
 import { generateAlphaNumericLowerCaseId } from "@webiny/utils";
 import { FieldSettings } from "~/admin/plugins/fieldRenderers/object/FieldSettings";
+import { useConfirmationDialog } from "@webiny/app-admin";
 
 const t = i18n.ns("app-headless-cms/admin/fields/text");
 
@@ -35,6 +36,12 @@ interface ActionsProps {
 }
 
 const Actions = ({ setHighlightIndex, bind, index }: ActionsProps) => {
+    const { showConfirmation } = useConfirmationDialog({
+        message: `Are you sure you want to delete this item? This action is not reversible.`,
+        acceptLabel: `Yes, I'm sure!`,
+        cancelLabel: `No, leave it.`
+    });
+
     const { moveValueDown, moveValueUp } = bind.field;
 
     const onDown = useCallback(
@@ -61,11 +68,21 @@ const Actions = ({ setHighlightIndex, bind, index }: ActionsProps) => {
         [moveValueUp, index]
     );
 
+    const onDelete = useCallback(
+        (ev: React.BaseSyntheticEvent) => {
+            ev.stopPropagation();
+            showConfirmation(() => {
+                bind.field.removeValue(index);
+            });
+        },
+        [index]
+    );
+
     return (
         <>
             <IconButton icon={<ArrowDown />} onClick={onDown} />
             <IconButton icon={<ArrowUp />} onClick={onUp} />
-            <IconButton icon={<DeleteIcon />} onClick={() => bind.field.removeValue(index)} />
+            <IconButton icon={<DeleteIcon />} onClick={onDelete} />
         </>
     );
 };
diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx
index bdcc6fe438b..24d35ac9f7f 100644
--- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx
+++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx
@@ -24,6 +24,7 @@ import {
 import { generateAlphaNumericLowerCaseId } from "@webiny/utils";
 import { FieldSettings } from "./FieldSettings";
 import { AccordionRenderSettings, getAccordionRenderSettings } from "../AccordionRenderSettings";
+import { useConfirmationDialog } from "@webiny/app-admin";
 
 const t = i18n.ns("app-headless-cms/admin/fields/text");
 
@@ -39,6 +40,12 @@ interface ActionsProps {
 const Actions = ({ setHighlightIndex, bind, index }: ActionsProps) => {
     const { moveValueDown, moveValueUp } = bind.field;
 
+    const { showConfirmation } = useConfirmationDialog({
+        message: `Are you sure you want to delete this item? This action is not reversible.`,
+        acceptLabel: `Yes, I'm sure!`,
+        cancelLabel: `No, leave it.`
+    });
+
     const onDown = useCallback(
         (ev: React.BaseSyntheticEvent) => {
             ev.stopPropagation();
@@ -63,11 +70,21 @@ const Actions = ({ setHighlightIndex, bind, index }: ActionsProps) => {
         [moveValueUp, index]
     );
 
+    const onDelete = useCallback(
+        (ev: React.BaseSyntheticEvent) => {
+            ev.stopPropagation();
+            showConfirmation(() => {
+                bind.field.removeValue(index);
+            });
+        },
+        [index]
+    );
+
     return (
         <>
             <IconButton icon={<ArrowDown />} onClick={onDown} />
             <IconButton icon={<ArrowUp />} onClick={onUp} />
-            <IconButton icon={<DeleteIcon />} onClick={() => bind.field.removeValue(index)} />
+            <IconButton icon={<DeleteIcon />} onClick={onDelete} />
         </>
     );
 };

From e359463f00237a5af8167aca60a0ff2821bbb5df Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bruno=20Zori=C4=87?= <bruno.zoric@gmail.com>
Date: Wed, 18 Dec 2024 11:54:07 +0100
Subject: [PATCH 17/52] fix(db-dynamodb): tools for batch write (#4445)

Co-authored-by: Pavel Denisjuk <pavel@webiny.com>
---
 .../src/definitions/entry.ts                  |   14 +-
 .../src/tasks/Manager.ts                      |   34 +-
 .../tasks/reindexing/ReindexingTaskRunner.ts  |   20 +-
 packages/api-elasticsearch-tasks/src/types.ts |   27 +-
 .../operations/AliasesStorageOperations.ts    |   52 +-
 .../src/definitions/elasticsearch.ts          |    1 -
 .../src/operations/form/index.ts              |  266 +++--
 .../src/operations/submission/index.ts        |    2 +-
 .../src/operations/form/index.ts              |  285 +++--
 .../__tests__/__api__/setupFile.js            |    3 +
 .../converters/convertersDisabled.test.ts     |    2 -
 .../__tests__/graphql/dummyLocales.ts         |    5 +-
 .../src/definitions/entryElasticsearch.ts     |    3 +
 .../dataLoader/getLatestRevisionByEntryId.ts  |    2 +-
 .../getPublishedRevisionByEntryId.ts          |    2 +-
 .../entry/dataLoader/getRevisionById.ts       |    2 +-
 .../src/operations/entry/index.ts             | 1034 ++++++++---------
 .../dataLoader/getLatestRevisionByEntryId.ts  |    2 +-
 .../getPublishedRevisionByEntryId.ts          |    2 +-
 .../entry/dataLoader/getRevisionById.ts       |    2 +-
 .../src/operations/entry/index.ts             |  765 ++++++------
 .../src/definitions/localeEntity.ts           |   18 +-
 .../src/definitions/systemEntity.ts           |   16 +-
 .../locales/LocalesStorageOperations.ts       |   93 +-
 .../system/SystemStorageOperations.ts         |   32 +-
 .../definitions/pageElasticsearchEntity.ts    |    3 +
 .../operations/blockCategory/dataLoader.ts    |    2 +-
 .../src/operations/category/dataLoader.ts     |    2 +-
 .../src/operations/pageBlock/dataLoader.ts    |    2 +-
 .../src/operations/pageTemplate/dataLoader.ts |    2 +-
 .../src/operations/pages/index.ts             |  517 ++++-----
 packages/api-page-builder-so-ddb/package.json |    3 +-
 .../operations/blockCategory/dataLoader.ts    |    2 +-
 .../src/operations/category/dataLoader.ts     |    2 +-
 .../src/operations/pageBlock/dataLoader.ts    |    2 +-
 .../src/operations/pageTemplate/dataLoader.ts |    2 +-
 .../src/operations/pages/index.ts             |  307 +++--
 .../src/operations/queueJob.ts                |   28 +-
 .../src/operations/render.ts                  |  101 +-
 packages/api-security-so-ddb/src/index.ts     |   75 +-
 packages/api-tenancy-so-ddb/src/index.ts      |  109 +-
 packages/db-dynamodb/README.md                |   82 +-
 packages/db-dynamodb/package.json             |    1 -
 packages/db-dynamodb/src/index.ts             |    2 +-
 packages/db-dynamodb/src/toolbox.ts           |    2 +
 .../src/utils/{ => batch}/batchRead.ts        |    0
 .../src/utils/{ => batch}/batchWrite.ts       |   21 +-
 packages/db-dynamodb/src/utils/batch/index.ts |    3 +
 packages/db-dynamodb/src/utils/batch/types.ts |   30 +
 packages/db-dynamodb/src/utils/delete.ts      |   14 +-
 .../db-dynamodb/src/utils/entity/Entity.ts    |  112 ++
 .../src/utils/entity/EntityReadBatch.ts       |   55 +
 .../utils/entity/EntityReadBatchBuilder.ts    |   34 +
 .../src/utils/entity/EntityWriteBatch.ts      |   77 ++
 .../utils/entity/EntityWriteBatchBuilder.ts   |   28 +
 .../db-dynamodb/src/utils/entity/getEntity.ts |   14 +
 .../db-dynamodb/src/utils/entity/index.ts     |    7 +
 .../db-dynamodb/src/utils/entity/types.ts     |   69 ++
 packages/db-dynamodb/src/utils/get.ts         |   10 +-
 packages/db-dynamodb/src/utils/index.ts       |    6 +-
 packages/db-dynamodb/src/utils/put.ts         |   19 +-
 packages/db-dynamodb/src/utils/scan.ts        |    8 +-
 packages/db-dynamodb/src/utils/table.ts       |   16 -
 packages/db-dynamodb/src/utils/table/Table.ts |   44 +
 .../src/utils/table/TableReadBatch.ts         |   75 ++
 .../src/utils/table/TableWriteBatch.ts        |   84 ++
 packages/db-dynamodb/src/utils/table/index.ts |    4 +
 packages/db-dynamodb/src/utils/table/types.ts |   53 +
 packages/db-dynamodb/src/utils/update.ts      |    4 +-
 packages/db-dynamodb/tsconfig.build.json      |    1 -
 packages/db-dynamodb/tsconfig.json            |    3 -
 .../5.35.0/001/ddb-es/FileDataMigration.ts    |   15 +-
 .../src/migrations/5.35.0/003/index.ts        |   42 +-
 .../src/migrations/5.35.0/005/index.ts        |   36 +-
 .../src/migrations/5.37.0/002/ddb-es/index.ts |    8 +-
 .../5.37.0/003/ddb-es/AcoFolderMigration.ts   |   33 +-
 .../src/migrations/5.40.0/001/ddb/index.ts    |   26 +-
 .../src/migrations/5.41.0/001/index.ts        |   38 +-
 .../src/utils/forEachTenantLocale.ts          |   12 +-
 yarn.lock                                     |    2 -
 80 files changed, 2763 insertions(+), 2170 deletions(-)
 rename packages/db-dynamodb/src/utils/{ => batch}/batchRead.ts (100%)
 rename packages/db-dynamodb/src/utils/{ => batch}/batchWrite.ts (79%)
 create mode 100644 packages/db-dynamodb/src/utils/batch/index.ts
 create mode 100644 packages/db-dynamodb/src/utils/batch/types.ts
 create mode 100644 packages/db-dynamodb/src/utils/entity/Entity.ts
 create mode 100644 packages/db-dynamodb/src/utils/entity/EntityReadBatch.ts
 create mode 100644 packages/db-dynamodb/src/utils/entity/EntityReadBatchBuilder.ts
 create mode 100644 packages/db-dynamodb/src/utils/entity/EntityWriteBatch.ts
 create mode 100644 packages/db-dynamodb/src/utils/entity/EntityWriteBatchBuilder.ts
 create mode 100644 packages/db-dynamodb/src/utils/entity/getEntity.ts
 create mode 100644 packages/db-dynamodb/src/utils/entity/index.ts
 create mode 100644 packages/db-dynamodb/src/utils/entity/types.ts
 delete mode 100644 packages/db-dynamodb/src/utils/table.ts
 create mode 100644 packages/db-dynamodb/src/utils/table/Table.ts
 create mode 100644 packages/db-dynamodb/src/utils/table/TableReadBatch.ts
 create mode 100644 packages/db-dynamodb/src/utils/table/TableWriteBatch.ts
 create mode 100644 packages/db-dynamodb/src/utils/table/index.ts
 create mode 100644 packages/db-dynamodb/src/utils/table/types.ts

diff --git a/packages/api-elasticsearch-tasks/src/definitions/entry.ts b/packages/api-elasticsearch-tasks/src/definitions/entry.ts
index fa3c9bb7afd..e2559d417f4 100644
--- a/packages/api-elasticsearch-tasks/src/definitions/entry.ts
+++ b/packages/api-elasticsearch-tasks/src/definitions/entry.ts
@@ -1,13 +1,18 @@
-import { Entity, TableDef } from "@webiny/db-dynamodb/toolbox";
+/**
+ * TODO If adding GSIs to the Elasticsearch table, add them here.
+ */
+import type { TableDef } from "@webiny/db-dynamodb/toolbox";
+import type { IEntity } from "@webiny/db-dynamodb";
+import { createEntity } from "@webiny/db-dynamodb";
 
 interface Params {
     table: TableDef;
     entityName: string;
 }
 
-export const createEntry = (params: Params): Entity<any> => {
+export const createEntry = (params: Params): IEntity => {
     const { table, entityName } = params;
-    return new Entity({
+    return createEntity({
         name: entityName,
         table,
         attributes: {
@@ -24,6 +29,9 @@ export const createEntry = (params: Params): Entity<any> => {
             },
             data: {
                 type: "map"
+            },
+            TYPE: {
+                type: "string"
             }
         }
     });
diff --git a/packages/api-elasticsearch-tasks/src/tasks/Manager.ts b/packages/api-elasticsearch-tasks/src/tasks/Manager.ts
index a62664742a0..2d981674e49 100644
--- a/packages/api-elasticsearch-tasks/src/tasks/Manager.ts
+++ b/packages/api-elasticsearch-tasks/src/tasks/Manager.ts
@@ -1,19 +1,16 @@
 import { DynamoDBDocument, getDocumentClient } from "@webiny/aws-sdk/client-dynamodb";
 import { Client, createElasticsearchClient } from "@webiny/api-elasticsearch";
 import { createTable } from "~/definitions";
-import { Context, IManager } from "~/types";
+import type { Context, IManager } from "~/types";
 import { createEntry } from "~/definitions/entry";
-import { Entity } from "@webiny/db-dynamodb/toolbox";
-import { ITaskResponse } from "@webiny/tasks/response/abstractions";
-import { IIsCloseToTimeoutCallable, ITaskManagerStore } from "@webiny/tasks/runner/abstractions";
-import {
-    batchReadAll,
-    BatchReadItem,
-    batchWriteAll,
-    BatchWriteItem,
-    BatchWriteResult
-} from "@webiny/db-dynamodb";
-import { ITimer } from "@webiny/handler-aws/utils";
+import type { ITaskResponse } from "@webiny/tasks/response/abstractions";
+import type {
+    IIsCloseToTimeoutCallable,
+    ITaskManagerStore
+} from "@webiny/tasks/runner/abstractions";
+import type { BatchReadItem, IEntity } from "@webiny/db-dynamodb";
+import { batchReadAll } from "@webiny/db-dynamodb";
+import type { ITimer } from "@webiny/handler-aws/utils";
 
 export interface ManagerParams<T> {
     context: Context;
@@ -37,7 +34,7 @@ export class Manager<T> implements IManager<T> {
     public readonly store: ITaskManagerStore<T>;
     public readonly timer: ITimer;
 
-    private readonly entities: Record<string, Entity<any>> = {};
+    private readonly entities: Record<string, IEntity> = {};
 
     public constructor(params: ManagerParams<T>) {
         this.context = params.context;
@@ -64,7 +61,7 @@ export class Manager<T> implements IManager<T> {
         this.timer = params.timer;
     }
 
-    public getEntity(name: string): Entity<any> {
+    public getEntity(name: string): IEntity {
         if (this.entities[name]) {
             return this.entities[name];
         }
@@ -75,17 +72,10 @@ export class Manager<T> implements IManager<T> {
         }));
     }
 
-    public async read<T>(items: BatchReadItem[]) {
+    public async read<T>(items: BatchReadItem[]): Promise<T[]> {
         return await batchReadAll<T>({
             table: this.table,
             items
         });
     }
-
-    public async write(items: BatchWriteItem[]): Promise<BatchWriteResult> {
-        return await batchWriteAll({
-            table: this.table,
-            items
-        });
-    }
 }
diff --git a/packages/api-elasticsearch-tasks/src/tasks/reindexing/ReindexingTaskRunner.ts b/packages/api-elasticsearch-tasks/src/tasks/reindexing/ReindexingTaskRunner.ts
index 908d8d911b7..930eb1b8166 100644
--- a/packages/api-elasticsearch-tasks/src/tasks/reindexing/ReindexingTaskRunner.ts
+++ b/packages/api-elasticsearch-tasks/src/tasks/reindexing/ReindexingTaskRunner.ts
@@ -6,7 +6,7 @@ import {
 } from "~/types";
 import { ITaskResponse, ITaskResponseResult } from "@webiny/tasks/response/abstractions";
 import { scan } from "~/helpers/scan";
-import { BatchWriteItem, ScanResponse } from "@webiny/db-dynamodb";
+import { createTableWriteBatch, ScanResponse } from "@webiny/db-dynamodb";
 import { IndexManager } from "~/settings";
 import { IIndexManager } from "~/settings/types";
 
@@ -73,7 +73,10 @@ export class ReindexingTaskRunner {
                     return this.response.done("No more items to process.");
                 }
 
-                const batch: BatchWriteItem[] = [];
+                const tableWriteBatch = createTableWriteBatch({
+                    table: this.manager.table
+                });
+
                 for (const item of results.items) {
                     /**
                      * No index defined? Impossible but let's skip if really happens.
@@ -110,14 +113,13 @@ export class ReindexingTaskRunner {
                     /**
                      * Reindexing will be triggered by the `putBatch` method.
                      */
-                    batch.push(
-                        entity.putBatch({
-                            ...item,
-                            modified: new Date().toISOString()
-                        })
-                    );
+                    tableWriteBatch.put(entity.entity, {
+                        ...item,
+                        TYPE: item.TYPE || "unknown",
+                        modified: new Date().toISOString()
+                    });
                 }
-                await this.manager.write(batch);
+                await tableWriteBatch.execute();
                 /**
                  * We always store the index settings, so we can restore them later.
                  * Also, we always want to store what was the last key we processed, just in case something breaks, so we can continue from this point.
diff --git a/packages/api-elasticsearch-tasks/src/types.ts b/packages/api-elasticsearch-tasks/src/types.ts
index b2d5b276551..8446398e1ee 100644
--- a/packages/api-elasticsearch-tasks/src/types.ts
+++ b/packages/api-elasticsearch-tasks/src/types.ts
@@ -1,17 +1,17 @@
-import { ElasticsearchContext } from "@webiny/api-elasticsearch/types";
-import { Entity } from "@webiny/db-dynamodb/toolbox";
-import {
+import type { ElasticsearchContext } from "@webiny/api-elasticsearch/types";
+import type {
     Context as TasksContext,
     IIsCloseToTimeoutCallable,
+    ITaskManagerStore,
+    ITaskResponse,
     ITaskResponseDoneResultOutput
 } from "@webiny/tasks/types";
-import { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb";
-import { Client } from "@webiny/api-elasticsearch";
+import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb";
+import type { Client } from "@webiny/api-elasticsearch";
 import { createTable } from "~/definitions";
-import { ITaskResponse } from "@webiny/tasks/response/abstractions";
-import { ITaskManagerStore } from "@webiny/tasks/runner/abstractions";
-import { BatchWriteItem, BatchWriteResult } from "@webiny/db-dynamodb";
-import { ITimer } from "@webiny/handler-aws";
+import type { BatchReadItem, IEntity } from "@webiny/db-dynamodb";
+import type { ITimer } from "@webiny/handler-aws";
+import type { GenericRecord } from "@webiny/api/types";
 
 export interface Context extends ElasticsearchContext, TasksContext {}
 
@@ -42,17 +42,18 @@ export interface IElasticsearchIndexingTaskValues {
 }
 
 export interface AugmentedError extends Error {
-    data?: Record<string, any>;
+    data?: GenericRecord;
     [key: string]: any;
 }
 
 export interface IDynamoDbElasticsearchRecord {
     PK: string;
     SK: string;
+    TYPE?: string;
     index: string;
     _et?: string;
     entity: string;
-    data: Record<string, any>;
+    data: GenericRecord;
     modified: string;
 }
 
@@ -70,7 +71,7 @@ export interface IManager<
     readonly store: ITaskManagerStore<T>;
     readonly timer: ITimer;
 
-    getEntity: (name: string) => Entity<any>;
+    getEntity: (name: string) => IEntity;
 
-    write: (items: BatchWriteItem[]) => Promise<BatchWriteResult>;
+    read<T>(items: BatchReadItem[]): Promise<T[]>;
 }
diff --git a/packages/api-file-manager-ddb/src/operations/AliasesStorageOperations.ts b/packages/api-file-manager-ddb/src/operations/AliasesStorageOperations.ts
index 47d2ed6280b..af1f8aba00d 100644
--- a/packages/api-file-manager-ddb/src/operations/AliasesStorageOperations.ts
+++ b/packages/api-file-manager-ddb/src/operations/AliasesStorageOperations.ts
@@ -1,13 +1,12 @@
-import { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb";
-import { Entity, Table } from "@webiny/db-dynamodb/toolbox";
-import {
-    FileManagerAliasesStorageOperations,
+import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb";
+import type { Entity, Table } from "@webiny/db-dynamodb/toolbox";
+import type {
     File,
-    FileAlias
+    FileAlias,
+    FileManagerAliasesStorageOperations
 } from "@webiny/api-file-manager/types";
 import {
-    BatchWriteItem,
-    batchWriteAll,
+    createEntityWriteBatch,
     createStandardEntity,
     createTable,
     DbItem,
@@ -39,52 +38,49 @@ export class AliasesStorageOperations implements FileManagerAliasesStorageOperat
 
     async deleteAliases(file: File): Promise<void> {
         const aliasItems = await this.getExistingAliases(file);
-        const items: BatchWriteItem[] = [];
 
-        aliasItems.forEach(item => {
-            items.push(
-                this.aliasEntity.deleteBatch({
+        const batchWrite = createEntityWriteBatch({
+            entity: this.aliasEntity,
+            delete: aliasItems.map(item => {
+                return {
                     PK: this.createPartitionKey({
                         id: item.fileId,
                         tenant: item.tenant,
                         locale: item.locale
                     }),
                     SK: `ALIAS#${item.alias}`
-                })
-            );
+                };
+            })
         });
 
-        await batchWriteAll({ table: this.table, items });
+        await batchWrite.execute();
     }
 
     async storeAliases(file: File): Promise<void> {
-        const items: BatchWriteItem[] = [];
         const existingAliases = await this.getExistingAliases(file);
         const newAliases = this.createNewAliasesRecords(file, existingAliases);
 
-        newAliases.forEach(alias => {
-            items.push(this.aliasEntity.putBatch(alias));
+        const batchWrite = createEntityWriteBatch({
+            entity: this.aliasEntity
         });
+        for (const alias of newAliases) {
+            batchWrite.put(alias);
+        }
 
         // Delete aliases that are in the DB but are NOT in the file.
         for (const data of existingAliases) {
             if (!file.aliases.some(alias => data.alias === alias)) {
-                items.push(
-                    this.aliasEntity.deleteBatch({
-                        PK: this.createPartitionKey(file),
-                        SK: `ALIAS#${data.alias}`
-                    })
-                );
+                batchWrite.delete({
+                    PK: this.createPartitionKey(file),
+                    SK: `ALIAS#${data.alias}`
+                });
             }
         }
 
-        await batchWriteAll({
-            table: this.table,
-            items
-        });
+        await batchWrite.execute();
     }
 
-    private async getExistingAliases(file: File) {
+    private async getExistingAliases(file: File): Promise<FileAlias[]> {
         const aliases = await queryAll<{ data: FileAlias }>({
             entity: this.aliasEntity,
             partitionKey: this.createPartitionKey(file),
diff --git a/packages/api-form-builder-so-ddb-es/src/definitions/elasticsearch.ts b/packages/api-form-builder-so-ddb-es/src/definitions/elasticsearch.ts
index 84f28c9538c..9236ce07eee 100644
--- a/packages/api-form-builder-so-ddb-es/src/definitions/elasticsearch.ts
+++ b/packages/api-form-builder-so-ddb-es/src/definitions/elasticsearch.ts
@@ -28,7 +28,6 @@ export const createElasticsearchEntity = (params: Params) => {
             TYPE: {
                 type: "string"
             },
-
             ...(attributes || {})
         }
     });
diff --git a/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts b/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts
index db1e3cf4c05..5ca078d4910 100644
--- a/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts
+++ b/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts
@@ -17,7 +17,7 @@ import { Entity, Table } from "@webiny/db-dynamodb/toolbox";
 import { Client } from "@elastic/elasticsearch";
 import { queryAll, QueryAllParams } from "@webiny/db-dynamodb/utils/query";
 import WebinyError from "@webiny/error";
-import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite";
+import { createEntityWriteBatch, getClean, IPutParamsItem, put } from "@webiny/db-dynamodb";
 import { configurations } from "~/configurations";
 import { filterItems } from "@webiny/db-dynamodb/utils/filter";
 import fields from "./fields";
@@ -28,7 +28,6 @@ import { decodeCursor, encodeCursor } from "@webiny/api-elasticsearch";
 import { PluginsContainer } from "@webiny/plugins";
 import { FormBuilderFormCreateKeyParams, FormBuilderFormStorageOperations } from "~/types";
 import { ElasticsearchSearchResponse } from "@webiny/api-elasticsearch/types";
-import { deleteItem, getClean, put } from "@webiny/db-dynamodb";
 
 export type DbRecord<T = any> = T & {
     PK: string;
@@ -71,7 +70,7 @@ const getESDataForLatestRevision = (form: FbForm): FbFormElastic => ({
 export const createFormStorageOperations = (
     params: CreateFormStorageOperationsParams
 ): FormBuilderFormStorageOperations => {
-    const { entity, esEntity, table, plugins, elasticsearch } = params;
+    const { entity, esEntity, plugins, elasticsearch } = params;
 
     const formDynamoDbFields = fields();
 
@@ -123,24 +122,24 @@ export const createFormStorageOperations = (
             SK: createLatestSortKey()
         };
 
-        const items = [
-            entity.putBatch({
-                ...form,
-                TYPE: createFormType(),
-                ...revisionKeys
-            }),
-            entity.putBatch({
-                ...form,
-                TYPE: createFormLatestType(),
-                ...latestKeys
-            })
-        ];
+        const itemsBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...form,
+                    TYPE: createFormType(),
+                    ...revisionKeys
+                },
+                {
+                    ...form,
+                    TYPE: createFormLatestType(),
+                    ...latestKeys
+                }
+            ]
+        });
 
         try {
-            await batchWriteAll({
-                table,
-                items
-            });
+            await itemsBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not insert form data into regular table.",
@@ -194,24 +193,24 @@ export const createFormStorageOperations = (
             SK: createLatestSortKey()
         };
 
-        const items = [
-            entity.putBatch({
-                ...form,
-                ...revisionKeys,
-                TYPE: createFormType()
-            }),
-            entity.putBatch({
-                ...form,
-                ...latestKeys,
-                TYPE: createFormLatestType()
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...form,
+                    ...revisionKeys,
+                    TYPE: createFormType()
+                },
+                {
+                    ...form,
+                    ...latestKeys,
+                    TYPE: createFormLatestType()
+                }
+            ]
+        });
 
         try {
-            await batchWriteAll({
-                table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message ||
@@ -283,27 +282,26 @@ export const createFormStorageOperations = (
         });
         const isLatestForm = latestForm ? latestForm.id === form.id : false;
 
-        const items = [
-            entity.putBatch({
-                ...form,
-                TYPE: createFormType(),
-                ...revisionKeys
-            })
-        ];
-        if (isLatestForm) {
-            items.push(
-                entity.putBatch({
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
                     ...form,
-                    TYPE: createFormLatestType(),
-                    ...latestKeys
-                })
-            );
+                    TYPE: createFormType(),
+                    ...revisionKeys
+                }
+            ]
+        });
+
+        if (isLatestForm) {
+            entityBatch.put({
+                ...form,
+                TYPE: createFormLatestType(),
+                ...latestKeys
+            });
         }
         try {
-            await batchWriteAll({
-                table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not update form data in the regular table.",
@@ -547,17 +545,18 @@ export const createFormStorageOperations = (
             );
         }
 
-        const deleteItems = items.map(item => {
-            return entity.deleteBatch({
-                PK: item.PK,
-                SK: item.SK
-            });
+        const deleteBatch = createEntityWriteBatch({
+            entity,
+            delete: items.map(item => {
+                return {
+                    PK: item.PK,
+                    SK: item.SK
+                };
+            })
         });
+
         try {
-            await batchWriteAll({
-                table,
-                items: deleteItems
-            });
+            await deleteBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not delete form and it's submissions.",
@@ -569,11 +568,13 @@ export const createFormStorageOperations = (
             PK: createFormPartitionKey(form),
             SK: createLatestSortKey()
         };
+        const deleteEsBatch = createEntityWriteBatch({
+            entity: esEntity,
+            delete: [latestKeys]
+        });
+
         try {
-            await deleteItem({
-                entity: esEntity,
-                keys: latestKeys
-            });
+            await deleteEsBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not delete latest form record from Elasticsearch.",
@@ -612,8 +613,12 @@ export const createFormStorageOperations = (
         const isLatest = latestForm ? latestForm.id === form.id : false;
         const isLatestPublished = latestPublishedForm ? latestPublishedForm.id === form.id : false;
 
-        const items = [entity.deleteBatch(revisionKeys)];
-        let esDataItem = undefined;
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            delete: [revisionKeys]
+        });
+
+        let esDataItem: IPutParamsItem | undefined = undefined;
 
         if (isLatest || isLatestPublished) {
             /**
@@ -630,34 +635,28 @@ export const createFormStorageOperations = (
                     })
                     .shift();
                 if (previouslyPublishedForm) {
-                    items.push(
-                        entity.putBatch({
-                            ...previouslyPublishedForm,
-                            PK: createFormPartitionKey(previouslyPublishedForm),
-                            SK: createLatestPublishedSortKey(),
-                            TYPE: createFormLatestPublishedType()
-                        })
-                    );
+                    entityBatch.put({
+                        ...previouslyPublishedForm,
+                        PK: createFormPartitionKey(previouslyPublishedForm),
+                        SK: createLatestPublishedSortKey(),
+                        TYPE: createFormLatestPublishedType()
+                    });
                 } else {
-                    items.push(
-                        entity.deleteBatch({
-                            PK: createFormPartitionKey(form),
-                            SK: createLatestPublishedSortKey()
-                        })
-                    );
+                    entityBatch.delete({
+                        PK: createFormPartitionKey(form),
+                        SK: createLatestPublishedSortKey()
+                    });
                 }
             }
             /**
              * Sort out the latest record.
              */
             if (isLatest && previous) {
-                items.push(
-                    entity.putBatch({
-                        ...previous,
-                        ...latestKeys,
-                        TYPE: createFormLatestType()
-                    })
-                );
+                entityBatch.put({
+                    ...previous,
+                    ...latestKeys,
+                    TYPE: createFormLatestType()
+                });
 
                 const { index } = configurations.es({
                     tenant: previous.tenant,
@@ -675,10 +674,7 @@ export const createFormStorageOperations = (
          * Now save the batch data.
          */
         try {
-            await batchWriteAll({
-                table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not delete form revision from regular table.",
@@ -759,36 +755,35 @@ export const createFormStorageOperations = (
         /**
          * Update revision and latest published records
          */
-        const items = [
-            entity.putBatch({
-                ...form,
-                ...revisionKeys,
-                TYPE: createFormType()
-            }),
-            entity.putBatch({
-                ...form,
-                ...latestPublishedKeys,
-                TYPE: createFormLatestPublishedType()
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...form,
+                    ...revisionKeys,
+                    TYPE: createFormType()
+                },
+                {
+                    ...form,
+                    ...latestPublishedKeys,
+                    TYPE: createFormLatestPublishedType()
+                }
+            ]
+        });
+
         /**
          * Update the latest form as well
          */
         if (isLatestForm) {
-            items.push(
-                entity.putBatch({
-                    ...form,
-                    ...latestKeys,
-                    TYPE: createFormLatestType()
-                })
-            );
+            entityBatch.put({
+                ...form,
+                ...latestKeys,
+                TYPE: createFormLatestType()
+            });
         }
 
         try {
-            await batchWriteAll({
-                table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not publish form.",
@@ -887,14 +882,18 @@ export const createFormStorageOperations = (
         const isLatest = latestForm ? latestForm.id === form.id : false;
         const isLatestPublished = latestPublishedForm ? latestPublishedForm.id === form.id : false;
 
-        const items = [
-            entity.putBatch({
-                ...form,
-                ...revisionKeys,
-                TYPE: createFormType()
-            })
-        ];
-        let esData: any = undefined;
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...form,
+                    ...revisionKeys,
+                    TYPE: createFormType()
+                }
+            ]
+        });
+
+        let esData: FbFormElastic | undefined = undefined;
         if (isLatest) {
             esData = getESDataForLatestRevision(form);
         }
@@ -916,23 +915,18 @@ export const createFormStorageOperations = (
 
             const previouslyPublishedRevision = revisions.shift();
             if (previouslyPublishedRevision) {
-                items.push(
-                    entity.putBatch({
-                        ...previouslyPublishedRevision,
-                        ...latestPublishedKeys,
-                        TYPE: createFormLatestPublishedType()
-                    })
-                );
+                entityBatch.put({
+                    ...previouslyPublishedRevision,
+                    ...latestPublishedKeys,
+                    TYPE: createFormLatestPublishedType()
+                });
             } else {
-                items.push(entity.deleteBatch(latestPublishedKeys));
+                entityBatch.delete(latestPublishedKeys);
             }
         }
 
         try {
-            await batchWriteAll({
-                table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not unpublish form.",
diff --git a/packages/api-form-builder-so-ddb-es/src/operations/submission/index.ts b/packages/api-form-builder-so-ddb-es/src/operations/submission/index.ts
index a1e1a00aafa..e93ee28b580 100644
--- a/packages/api-form-builder-so-ddb-es/src/operations/submission/index.ts
+++ b/packages/api-form-builder-so-ddb-es/src/operations/submission/index.ts
@@ -10,7 +10,7 @@ import {
 import { Entity, Table } from "@webiny/db-dynamodb/toolbox";
 import { Client } from "@elastic/elasticsearch";
 import WebinyError from "@webiny/error";
-import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead";
+import { batchReadAll } from "@webiny/db-dynamodb";
 import { sortItems } from "@webiny/db-dynamodb/utils/sort";
 import { createLimit, decodeCursor, encodeCursor } from "@webiny/api-elasticsearch";
 import {
diff --git a/packages/api-form-builder-so-ddb/src/operations/form/index.ts b/packages/api-form-builder-so-ddb/src/operations/form/index.ts
index 3ade575baa2..c25f60780dc 100644
--- a/packages/api-form-builder-so-ddb/src/operations/form/index.ts
+++ b/packages/api-form-builder-so-ddb/src/operations/form/index.ts
@@ -1,5 +1,5 @@
 import WebinyError from "@webiny/error";
-import {
+import type {
     FbForm,
     FormBuilderStorageOperationsCreateFormFromParams,
     FormBuilderStorageOperationsCreateFormParams,
@@ -14,14 +14,14 @@ import {
     FormBuilderStorageOperationsUnpublishFormParams,
     FormBuilderStorageOperationsUpdateFormParams
 } from "@webiny/api-form-builder/types";
-import { Entity, Table } from "@webiny/db-dynamodb/toolbox";
+import type { Entity, Table } from "@webiny/db-dynamodb/toolbox";
 import { queryAll, QueryAllParams } from "@webiny/db-dynamodb/utils/query";
-import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite";
+import { createEntityWriteBatch } from "@webiny/db-dynamodb";
 import { filterItems } from "@webiny/db-dynamodb/utils/filter";
 import { sortItems } from "@webiny/db-dynamodb/utils/sort";
 import { createIdentifier, parseIdentifier } from "@webiny/utils";
-import { PluginsContainer } from "@webiny/plugins";
-import {
+import type { PluginsContainer } from "@webiny/plugins";
+import type {
     FormBuilderFormCreateGSIPartitionKeyParams,
     FormBuilderFormCreatePartitionKeyParams,
     FormBuilderFormStorageOperations
@@ -60,7 +60,7 @@ export interface CreateFormStorageOperationsParams {
 export const createFormStorageOperations = (
     params: CreateFormStorageOperationsParams
 ): FormBuilderFormStorageOperations => {
-    const { entity, table, plugins } = params;
+    const { entity, plugins } = params;
 
     const formDynamoDbFields = plugins.byType<FormDynamoDbFieldPlugin>(
         FormDynamoDbFieldPlugin.type
@@ -123,28 +123,30 @@ export const createFormStorageOperations = (
         return "fb.form.latestPublished";
     };
 
-    const createRevisionKeys = (form: FbForm): Keys => {
+    const createRevisionKeys = (form: Pick<FbForm, "id" | "tenant" | "locale">): Keys => {
         return {
             PK: createFormPartitionKey(form),
             SK: createRevisionSortKey(form)
         };
     };
 
-    const createLatestKeys = (form: FbForm): Keys => {
+    const createLatestKeys = (form: Pick<FbForm, "tenant" | "locale" | "id" | "formId">): Keys => {
         return {
             PK: createFormLatestPartitionKey(form),
             SK: createFormLatestSortKey(form)
         };
     };
 
-    const createLatestPublishedKeys = (form: FbForm): Keys => {
+    const createLatestPublishedKeys = (
+        form: Pick<FbForm, "tenant" | "locale" | "id" | "formId">
+    ): Keys => {
         return {
             PK: createFormLatestPublishedPartitionKey(form),
             SK: createLatestPublishedSortKey(form)
         };
     };
 
-    const createGSIKeys = (form: FbForm): GsiKeys => {
+    const createGSIKeys = (form: Pick<FbForm, "version" | "tenant" | "locale">): GsiKeys => {
         return {
             GSI1_PK: createFormGSIPartitionKey(form),
             GSI1_SK: createGSISortKey(form.version)
@@ -160,25 +162,25 @@ export const createFormStorageOperations = (
         const latestKeys = createLatestKeys(form);
         const gsiKeys = createGSIKeys(form);
 
-        const items = [
-            entity.putBatch({
-                ...form,
-                ...revisionKeys,
-                ...gsiKeys,
-                TYPE: createFormType()
-            }),
-            entity.putBatch({
-                ...form,
-                ...latestKeys,
-                TYPE: createFormLatestType()
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...form,
+                    ...revisionKeys,
+                    ...gsiKeys,
+                    TYPE: createFormType()
+                },
+                {
+                    ...form,
+                    ...latestKeys,
+                    TYPE: createFormLatestType()
+                }
+            ]
+        });
 
         try {
-            await batchWriteAll({
-                table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not insert form data into table.",
@@ -202,25 +204,25 @@ export const createFormStorageOperations = (
         const latestKeys = createLatestKeys(form);
         const gsiKeys = createGSIKeys(form);
 
-        const items = [
-            entity.putBatch({
-                ...form,
-                ...revisionKeys,
-                ...gsiKeys,
-                TYPE: createFormType()
-            }),
-            entity.putBatch({
-                ...form,
-                ...latestKeys,
-                TYPE: createFormLatestType()
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...form,
+                    ...revisionKeys,
+                    ...gsiKeys,
+                    TYPE: createFormType()
+                },
+                {
+                    ...form,
+                    ...latestKeys,
+                    TYPE: createFormLatestType()
+                }
+            ]
+        });
 
         try {
-            await batchWriteAll({
-                table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not create form data in the table, from existing form.",
@@ -259,28 +261,27 @@ export const createFormStorageOperations = (
         });
         const isLatestForm = latestForm ? latestForm.id === form.id : false;
 
-        const items = [
-            entity.putBatch({
-                ...form,
-                ...revisionKeys,
-                ...gsiKeys,
-                TYPE: createFormType()
-            })
-        ];
-        if (isLatestForm) {
-            items.push(
-                entity.putBatch({
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
                     ...form,
-                    ...latestKeys,
-                    TYPE: createFormLatestType()
-                })
-            );
+                    ...revisionKeys,
+                    ...gsiKeys,
+                    TYPE: createFormType()
+                }
+            ]
+        });
+
+        if (isLatestForm) {
+            entityBatch.put({
+                ...form,
+                ...latestKeys,
+                TYPE: createFormLatestType()
+            });
         }
         try {
-            await batchWriteAll({
-                table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not update form data in the table.",
@@ -510,29 +511,27 @@ export const createFormStorageOperations = (
                 }
             );
         }
+        let latestPublishedKeys: Keys | undefined;
+        const entityBatch = createEntityWriteBatch({
+            entity
+        });
 
-        let hasLatestPublishedRecord = false;
-
-        const deleteItems = items.map(item => {
-            if (!hasLatestPublishedRecord && item.published) {
-                hasLatestPublishedRecord = true;
+        for (const item of items) {
+            if (!latestPublishedKeys && item.published) {
+                latestPublishedKeys = createLatestPublishedKeys(item);
             }
-            return entity.deleteBatch({
+            entityBatch.delete({
                 PK: item.PK,
                 SK: item.SK
             });
-        });
-        if (hasLatestPublishedRecord) {
-            deleteItems.push(entity.deleteBatch(createLatestPublishedKeys(items[0])));
         }
 
-        deleteItems.push(entity.deleteBatch(createLatestKeys(items[0])));
+        if (latestPublishedKeys) {
+            entityBatch.delete(latestPublishedKeys);
+        }
 
         try {
-            await batchWriteAll({
-                table,
-                items: deleteItems
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not delete form and it's submissions.",
@@ -561,7 +560,10 @@ export const createFormStorageOperations = (
         const isLatest = latestForm ? latestForm.id === form.id : false;
         const isLatestPublished = latestPublishedForm ? latestPublishedForm.id === form.id : false;
 
-        const items = [entity.deleteBatch(revisionKeys)];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            delete: [revisionKeys]
+        });
 
         if (isLatest || isLatestPublished) {
             /**
@@ -578,42 +580,36 @@ export const createFormStorageOperations = (
                     })
                     .shift();
                 if (previouslyPublishedForm) {
-                    items.push(
-                        entity.putBatch({
-                            ...previouslyPublishedForm,
-                            ...createLatestPublishedKeys(previouslyPublishedForm),
-                            GSI1_PK: null,
-                            GSI1_SK: null,
-                            TYPE: createFormLatestPublishedType()
-                        })
-                    );
+                    entityBatch.put({
+                        ...previouslyPublishedForm,
+                        ...createLatestPublishedKeys(previouslyPublishedForm),
+                        GSI1_PK: null,
+                        GSI1_SK: null,
+                        TYPE: createFormLatestPublishedType()
+                    });
                 } else {
-                    items.push(entity.deleteBatch(createLatestPublishedKeys(form)));
+                    entityBatch.delete(createLatestPublishedKeys(form));
                 }
             }
             /**
              * Sort out the latest record.
              */
             if (isLatest) {
-                items.push(
-                    entity.putBatch({
-                        ...previous,
-                        ...latestKeys,
-                        GSI1_PK: null,
-                        GSI1_SK: null,
-                        TYPE: createFormLatestType()
-                    })
-                );
+                entityBatch.put({
+                    ...previous,
+                    ...latestKeys,
+                    GSI1_PK: null,
+                    GSI1_SK: null,
+                    TYPE: createFormLatestType()
+                });
             }
         }
         /**
          * Now save the batch data.
          */
         try {
-            await batchWriteAll({
-                table,
-                items
-            });
+            await entityBatch.execute();
+
             return form;
         } catch (ex) {
             throw new WebinyError(
@@ -667,37 +663,36 @@ export const createFormStorageOperations = (
         /**
          * Update revision and latest published records
          */
-        const items = [
-            entity.putBatch({
-                ...form,
-                ...revisionKeys,
-                ...gsiKeys,
-                TYPE: createFormType()
-            }),
-            entity.putBatch({
-                ...form,
-                ...latestPublishedKeys,
-                TYPE: createFormLatestPublishedType()
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...form,
+                    ...revisionKeys,
+                    ...gsiKeys,
+                    TYPE: createFormType()
+                },
+                {
+                    ...form,
+                    ...latestPublishedKeys,
+                    TYPE: createFormLatestPublishedType()
+                }
+            ]
+        });
+
         /**
          * Update the latest form as well
          */
         if (isLatestForm) {
-            items.push(
-                entity.putBatch({
-                    ...form,
-                    ...latestKeys,
-                    TYPE: createFormLatestType()
-                })
-            );
+            entityBatch.put({
+                ...form,
+                ...latestKeys,
+                TYPE: createFormLatestType()
+            });
         }
 
         try {
-            await batchWriteAll({
-                table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not publish form.",
@@ -754,17 +749,20 @@ export const createFormStorageOperations = (
         const isLatest = latestForm ? latestForm.id === form.id : false;
         const isLatestPublished = latestPublishedForm ? latestPublishedForm.id === form.id : false;
 
-        const items = [
-            entity.putBatch({
-                ...form,
-                ...revisionKeys,
-                ...gsiKeys,
-                TYPE: createFormType()
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...form,
+                    ...revisionKeys,
+                    ...gsiKeys,
+                    TYPE: createFormType()
+                }
+            ]
+        });
 
         if (isLatest) {
-            entity.putBatch({
+            entityBatch.put({
                 ...form,
                 ...latestKeys,
                 TYPE: createFormLatestType()
@@ -788,23 +786,18 @@ export const createFormStorageOperations = (
 
             const previouslyPublishedRevision = revisions.shift();
             if (previouslyPublishedRevision) {
-                items.push(
-                    entity.putBatch({
-                        ...previouslyPublishedRevision,
-                        ...latestPublishedKeys,
-                        TYPE: createFormLatestPublishedType()
-                    })
-                );
+                entityBatch.put({
+                    ...previouslyPublishedRevision,
+                    ...latestPublishedKeys,
+                    TYPE: createFormLatestPublishedType()
+                });
             } else {
-                items.push(entity.deleteBatch(latestPublishedKeys));
+                entityBatch.delete(latestPublishedKeys);
             }
         }
 
         try {
-            await batchWriteAll({
-                table,
-                items
-            });
+            await entityBatch.execute();
             return form;
         } catch (ex) {
             throw new WebinyError(
diff --git a/packages/api-headless-cms-ddb-es/__tests__/__api__/setupFile.js b/packages/api-headless-cms-ddb-es/__tests__/__api__/setupFile.js
index e657c72bbb3..a436a3f75d9 100644
--- a/packages/api-headless-cms-ddb-es/__tests__/__api__/setupFile.js
+++ b/packages/api-headless-cms-ddb-es/__tests__/__api__/setupFile.js
@@ -66,6 +66,9 @@ module.exports = () => {
             });
         });
 
+        createOrRefreshIndexSubscription.name =
+            "headlessCmsDdbEs.context.createOrRefreshIndexSubscription";
+
         return {
             storageOperations: createStorageOperations({
                 documentClient,
diff --git a/packages/api-headless-cms-ddb-es/__tests__/converters/convertersDisabled.test.ts b/packages/api-headless-cms-ddb-es/__tests__/converters/convertersDisabled.test.ts
index b3193273d49..dac86634f7c 100644
--- a/packages/api-headless-cms-ddb-es/__tests__/converters/convertersDisabled.test.ts
+++ b/packages/api-headless-cms-ddb-es/__tests__/converters/convertersDisabled.test.ts
@@ -7,8 +7,6 @@ import { CmsModel } from "@webiny/api-headless-cms/types";
 import { get } from "@webiny/db-dynamodb";
 import { createPartitionKey } from "~/operations/entry/keys";
 
-jest.retryTimes(0);
-
 describe("storage field path converters disabled", () => {
     const { elasticsearch, entryEntity } = useHandler();
 
diff --git a/packages/api-headless-cms-ddb-es/__tests__/graphql/dummyLocales.ts b/packages/api-headless-cms-ddb-es/__tests__/graphql/dummyLocales.ts
index 9424ee10e21..f6b763e697c 100644
--- a/packages/api-headless-cms-ddb-es/__tests__/graphql/dummyLocales.ts
+++ b/packages/api-headless-cms-ddb-es/__tests__/graphql/dummyLocales.ts
@@ -2,7 +2,7 @@ import { ContextPlugin } from "@webiny/api";
 import { CmsContext } from "@webiny/api-headless-cms/types";
 
 export const createDummyLocales = () => {
-    return new ContextPlugin<CmsContext>(async context => {
+    const plugin = new ContextPlugin<CmsContext>(async context => {
         const { i18n, security } = context;
 
         await security.withoutAuthorization(async () => {
@@ -23,4 +23,7 @@ export const createDummyLocales = () => {
             });
         });
     });
+
+    plugin.name = "headlessCmsDdbEs.context.createDummyLocales";
+    return plugin;
 };
diff --git a/packages/api-headless-cms-ddb-es/src/definitions/entryElasticsearch.ts b/packages/api-headless-cms-ddb-es/src/definitions/entryElasticsearch.ts
index 97bffededb1..338b1b42af0 100644
--- a/packages/api-headless-cms-ddb-es/src/definitions/entryElasticsearch.ts
+++ b/packages/api-headless-cms-ddb-es/src/definitions/entryElasticsearch.ts
@@ -28,6 +28,9 @@ export const createEntryElasticsearchEntity = (
             data: {
                 type: "map"
             },
+            TYPE: {
+                type: "string"
+            },
             ...(attributes || {})
         }
     });
diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts
index 1e35ed56305..6338066f712 100644
--- a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts
+++ b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts
@@ -1,5 +1,5 @@
 import DataLoader from "dataloader";
-import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead";
+import { batchReadAll } from "@webiny/db-dynamodb";
 import { cleanupItems } from "@webiny/db-dynamodb/utils/cleanup";
 import { CmsStorageEntry } from "@webiny/api-headless-cms/types";
 import { createBatchScheduleFn } from "./createBatchScheduleFn";
diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts
index 5e510a2a714..d6a409bd913 100644
--- a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts
+++ b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts
@@ -1,5 +1,5 @@
 import DataLoader from "dataloader";
-import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead";
+import { batchReadAll } from "@webiny/db-dynamodb";
 import { cleanupItems } from "@webiny/db-dynamodb/utils/cleanup";
 import { CmsStorageEntry } from "@webiny/api-headless-cms/types";
 import { createPartitionKey, createPublishedSortKey } from "~/operations/entry/keys";
diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getRevisionById.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getRevisionById.ts
index 8374efbcb72..4458e9a34fc 100644
--- a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getRevisionById.ts
+++ b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getRevisionById.ts
@@ -1,5 +1,5 @@
 import DataLoader from "dataloader";
-import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead";
+import { batchReadAll } from "@webiny/db-dynamodb";
 import { CmsStorageEntry } from "@webiny/api-headless-cms/types";
 import { cleanupItems } from "@webiny/db-dynamodb/utils/cleanup";
 import { createPartitionKey, createRevisionSortKey } from "~/operations/entry/keys";
diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/index.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/index.ts
index 574423f8119..f5d098b658e 100644
--- a/packages/api-headless-cms-ddb-es/src/operations/entry/index.ts
+++ b/packages/api-headless-cms-ddb-es/src/operations/entry/index.ts
@@ -1,17 +1,25 @@
 import WebinyError from "@webiny/error";
-import {
+import type {
     CmsEntry,
     CmsModel,
     CmsStorageEntry,
-    CONTENT_ENTRY_STATUS,
     StorageOperationsCmsModel
 } from "@webiny/api-headless-cms/types";
+import { CONTENT_ENTRY_STATUS } from "@webiny/api-headless-cms/types";
 import { extractEntriesFromIndex } from "~/helpers";
 import { configurations } from "~/configurations";
-import { Entity } from "@webiny/db-dynamodb/toolbox";
-import { Client } from "@elastic/elasticsearch";
-import { PluginsContainer } from "@webiny/plugins";
-import { batchWriteAll, BatchWriteItem } from "@webiny/db-dynamodb/utils/batchWrite";
+import type { Entity } from "@webiny/db-dynamodb/toolbox";
+import type { Client } from "@elastic/elasticsearch";
+import type { PluginsContainer } from "@webiny/plugins";
+import type { BatchReadItem, QueryAllParams, QueryOneParams } from "@webiny/db-dynamodb";
+import {
+    batchReadAll,
+    cleanupItem,
+    createEntityWriteBatch,
+    getClean,
+    queryAll,
+    queryOne
+} from "@webiny/db-dynamodb";
 import { DataLoadersHandler } from "./dataLoaders";
 import {
     createLatestSortKey,
@@ -19,12 +27,6 @@ import {
     createPublishedSortKey,
     createRevisionSortKey
 } from "./keys";
-import {
-    queryAll,
-    QueryAllParams,
-    queryOne,
-    QueryOneParams
-} from "@webiny/db-dynamodb/utils/query";
 import {
     compress,
     createLimit,
@@ -32,21 +34,17 @@ import {
     decompress,
     encodeCursor
 } from "@webiny/api-elasticsearch";
-import { getClean } from "@webiny/db-dynamodb/utils/get";
 import { zeroPad } from "@webiny/utils";
-import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup";
-import {
+import type {
     ElasticsearchSearchResponse,
     SearchBody as ElasticsearchSearchBody
 } from "@webiny/api-elasticsearch/types";
-import { CmsEntryStorageOperations, CmsIndexEntry } from "~/types";
+import type { CmsEntryStorageOperations, CmsIndexEntry } from "~/types";
 import { createElasticsearchBody } from "./elasticsearch/body";
 import { logIgnoredEsResponseError } from "./elasticsearch/logIgnoredEsResponseError";
 import { shouldIgnoreEsResponseError } from "./elasticsearch/shouldIgnoreEsResponseError";
 import { createLatestRecordType, createPublishedRecordType, createRecordType } from "./recordType";
 import { StorageOperationsCmsModelPlugin } from "@webiny/api-headless-cms";
-import { WriteRequest } from "@webiny/aws-sdk/client-dynamodb";
-import { batchReadAll, BatchReadItem } from "@webiny/db-dynamodb";
 import { createTransformer } from "./transformations";
 import { convertEntryKeysFromStorage } from "./transformations/convertEntryKeys";
 import {
@@ -57,6 +55,9 @@ import {
 } from "@webiny/api-headless-cms/constants";
 
 interface ElasticsearchDbRecord {
+    PK: string;
+    SK: string;
+    TYPE: string;
     index: string;
     data: Record<string, any>;
 }
@@ -164,37 +165,35 @@ export const createEntriesStorageOperations = (
             SK: createPublishedSortKey()
         };
 
-        const items = [
-            entity.putBatch({
-                ...storageEntry,
-                locked,
-                ...revisionKeys,
-                TYPE: createRecordType()
-            }),
-            entity.putBatch({
-                ...storageEntry,
-                locked,
-                ...latestKeys,
-                TYPE: createLatestRecordType()
-            })
-        ];
-
-        if (isPublished) {
-            items.push(
-                entity.putBatch({
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
                     ...storageEntry,
                     locked,
-                    ...publishedKeys,
-                    TYPE: createPublishedRecordType()
-                })
-            );
+                    ...revisionKeys,
+                    TYPE: createRecordType()
+                },
+                {
+                    ...storageEntry,
+                    locked,
+                    ...latestKeys,
+                    TYPE: createLatestRecordType()
+                }
+            ]
+        });
+
+        if (isPublished) {
+            entityBatch.put({
+                ...storageEntry,
+                locked,
+                ...publishedKeys,
+                TYPE: createPublishedRecordType()
+            });
         }
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
             dataLoaders.clearAll({
                 model
             });
@@ -211,29 +210,29 @@ export const createEntriesStorageOperations = (
         }
 
         const esLatestData = await transformer.getElasticsearchLatestEntryData();
-        const esItems: BatchWriteItem[] = [
-            esEntity.putBatch({
-                ...latestKeys,
-                index: esIndex,
-                data: esLatestData
-            })
-        ];
+
+        const elasticsearchEntityBatch = createEntityWriteBatch({
+            entity: esEntity,
+            put: [
+                {
+                    ...latestKeys,
+                    index: esIndex,
+                    data: esLatestData
+                }
+            ]
+        });
+
         if (isPublished) {
             const esPublishedData = await transformer.getElasticsearchPublishedEntryData();
-            esItems.push(
-                esEntity.putBatch({
-                    ...publishedKeys,
-                    index: esIndex,
-                    data: esPublishedData
-                })
-            );
+            elasticsearchEntityBatch.put({
+                ...publishedKeys,
+                index: esIndex,
+                data: esPublishedData
+            });
         }
 
         try {
-            await batchWriteAll({
-                table: esEntity.table,
-                items: esItems
-            });
+            await elasticsearchEntityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not insert entry data into the Elasticsearch DynamoDB table.",
@@ -295,27 +294,28 @@ export const createEntriesStorageOperations = (
 
         const esLatestData = await transformer.getElasticsearchLatestEntryData();
 
-        const items = [
-            entity.putBatch({
-                ...storageEntry,
-                TYPE: createRecordType(),
-                ...revisionKeys
-            }),
-            entity.putBatch({
-                ...storageEntry,
-                TYPE: createLatestRecordType(),
-                ...latestKeys
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...storageEntry,
+                    TYPE: createRecordType(),
+                    ...revisionKeys
+                },
+                {
+                    ...storageEntry,
+                    TYPE: createLatestRecordType(),
+                    ...latestKeys
+                }
+            ]
+        });
 
         if (isPublished) {
-            items.push(
-                entity.putBatch({
-                    ...storageEntry,
-                    TYPE: createPublishedRecordType(),
-                    ...publishedKeys
-                })
-            );
+            entityBatch.put({
+                ...storageEntry,
+                TYPE: createPublishedRecordType(),
+                ...publishedKeys
+            });
 
             // Unpublish previously published revision (if any).
             const [publishedRevisionStorageEntry] = await dataLoaders.getPublishedRevisionByEntryId(
@@ -326,27 +326,23 @@ export const createEntriesStorageOperations = (
             );
 
             if (publishedRevisionStorageEntry) {
-                items.push(
-                    entity.putBatch({
-                        ...publishedRevisionStorageEntry,
-                        PK: createPartitionKey({
-                            id: publishedRevisionStorageEntry.id,
-                            locale: model.locale,
-                            tenant: model.tenant
-                        }),
-                        SK: createRevisionSortKey(publishedRevisionStorageEntry),
-                        TYPE: createRecordType(),
-                        status: CONTENT_ENTRY_STATUS.UNPUBLISHED
-                    })
-                );
+                entityBatch.put({
+                    ...publishedRevisionStorageEntry,
+                    PK: createPartitionKey({
+                        id: publishedRevisionStorageEntry.id,
+                        locale: model.locale,
+                        tenant: model.tenant
+                    }),
+                    SK: createRevisionSortKey(publishedRevisionStorageEntry),
+                    TYPE: createRecordType(),
+                    status: CONTENT_ENTRY_STATUS.UNPUBLISHED
+                });
             }
         }
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
+
             dataLoaders.clearAll({
                 model
             });
@@ -366,30 +362,28 @@ export const createEntriesStorageOperations = (
             model
         });
 
-        const esItems: BatchWriteItem[] = [
-            esEntity.putBatch({
-                ...latestKeys,
-                index: esIndex,
-                data: esLatestData
-            })
-        ];
+        const elasticsearchEntityBatch = createEntityWriteBatch({
+            entity: esEntity,
+            put: [
+                {
+                    ...latestKeys,
+                    index: esIndex,
+                    data: esLatestData
+                }
+            ]
+        });
 
         if (isPublished) {
             const esPublishedData = await transformer.getElasticsearchPublishedEntryData();
-            esItems.push(
-                esEntity.putBatch({
-                    ...publishedKeys,
-                    index: esIndex,
-                    data: esPublishedData
-                })
-            );
+            elasticsearchEntityBatch.put({
+                ...publishedKeys,
+                index: esIndex,
+                data: esPublishedData
+            });
         }
 
         try {
-            await batchWriteAll({
-                table: esEntity.table,
-                items: esItems
-            });
+            await elasticsearchEntityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not update latest entry in the DynamoDB Elasticsearch table.",
@@ -461,26 +455,30 @@ export const createEntriesStorageOperations = (
             ids: [entry.id]
         });
 
-        const items = [
-            entity.putBatch({
-                ...storageEntry,
-                locked,
-                ...revisionKeys,
-                TYPE: createRecordType()
-            })
-        ];
-        if (isPublished) {
-            items.push(
-                entity.putBatch({
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
                     ...storageEntry,
                     locked,
-                    ...publishedKeys,
-                    TYPE: createPublishedRecordType()
-                })
-            );
+                    ...revisionKeys,
+                    TYPE: createRecordType()
+                }
+            ]
+        });
+
+        if (isPublished) {
+            entityBatch.put({
+                ...storageEntry,
+                locked,
+                ...publishedKeys,
+                TYPE: createPublishedRecordType()
+            });
         }
 
-        const esItems: BatchWriteItem[] = [];
+        const elasticsearchEntityBatch = createEntityWriteBatch({
+            entity: esEntity
+        });
 
         const { index: esIndex } = configurations.es({
             model
@@ -495,26 +493,22 @@ export const createEntriesStorageOperations = (
                 /**
                  * First we update the regular DynamoDB table.
                  */
-                items.push(
-                    entity.putBatch({
-                        ...storageEntry,
-                        ...latestKeys,
-                        TYPE: createLatestRecordType()
-                    })
-                );
+                entityBatch.put({
+                    ...storageEntry,
+                    ...latestKeys,
+                    TYPE: createLatestRecordType()
+                });
 
                 /**
                  * And then update the Elasticsearch table to propagate changes to the Elasticsearch
                  */
                 const elasticsearchLatestData = await transformer.getElasticsearchLatestEntryData();
 
-                esItems.push(
-                    esEntity.putBatch({
-                        ...latestKeys,
-                        index: esIndex,
-                        data: elasticsearchLatestData
-                    })
-                );
+                elasticsearchEntityBatch.put({
+                    ...latestKeys,
+                    index: esIndex,
+                    data: elasticsearchLatestData
+                });
             } else {
                 /**
                  * If not updating latest revision, we still want to update the latest revision's
@@ -536,25 +530,21 @@ export const createEntriesStorageOperations = (
                  * - one for the actual revision record
                  * - one for the latest record
                  */
-                items.push(
-                    entity.putBatch({
-                        ...updatedLatestStorageEntry,
-                        PK: createPartitionKey({
-                            id: latestStorageEntry.id,
-                            locale: model.locale,
-                            tenant: model.tenant
-                        }),
-                        SK: createRevisionSortKey(latestStorageEntry),
-                        TYPE: createRecordType()
-                    })
-                );
+                entityBatch.put({
+                    ...updatedLatestStorageEntry,
+                    PK: createPartitionKey({
+                        id: latestStorageEntry.id,
+                        locale: model.locale,
+                        tenant: model.tenant
+                    }),
+                    SK: createRevisionSortKey(latestStorageEntry),
+                    TYPE: createRecordType()
+                });
 
-                items.push(
-                    entity.putBatch({
-                        ...updatedLatestStorageEntry,
-                        TYPE: createLatestRecordType()
-                    })
-                );
+                entityBatch.put({
+                    ...updatedLatestStorageEntry,
+                    TYPE: createLatestRecordType()
+                });
 
                 /**
                  * Update the Elasticsearch table to propagate changes to the Elasticsearch.
@@ -575,13 +565,11 @@ export const createEntriesStorageOperations = (
                         ...updatedEntryLevelMetaFields
                     });
 
-                    esItems.push(
-                        esEntity.putBatch({
-                            ...latestKeys,
-                            index: esIndex,
-                            data: updatedLatestEntry
-                        })
-                    );
+                    elasticsearchEntityBatch.put({
+                        ...latestKeys,
+                        index: esIndex,
+                        data: updatedLatestEntry
+                    });
                 }
             }
         }
@@ -589,19 +577,15 @@ export const createEntriesStorageOperations = (
         if (isPublished && publishedStorageEntry?.id === entry.id) {
             const elasticsearchPublishedData =
                 await transformer.getElasticsearchPublishedEntryData();
-            esItems.push(
-                esEntity.putBatch({
-                    ...publishedKeys,
-                    index: esIndex,
-                    data: elasticsearchPublishedData
-                })
-            );
+            elasticsearchEntityBatch.put({
+                ...publishedKeys,
+                index: esIndex,
+                data: elasticsearchPublishedData
+            });
         }
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
+
             dataLoaders.clearAll({
                 model
             });
@@ -616,15 +600,8 @@ export const createEntriesStorageOperations = (
                 }
             );
         }
-        if (esItems.length === 0) {
-            return initialStorageEntry;
-        }
-
         try {
-            await batchWriteAll({
-                table: esEntity.table,
-                items: esItems
-            });
+            await elasticsearchEntityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not update entry DynamoDB Elasticsearch records.",
@@ -664,17 +641,19 @@ export const createEntriesStorageOperations = (
          */
         let latestRecord: CmsEntry | undefined = undefined;
         let publishedRecord: CmsEntry | undefined = undefined;
-        const items: BatchWriteItem[] = [];
+        const entityBatch = createEntityWriteBatch({
+            entity
+        });
+
         for (const record of records) {
-            items.push(
-                entity.putBatch({
-                    ...record,
-                    location: {
-                        ...record?.location,
-                        folderId
-                    }
-                })
-            );
+            entityBatch.put({
+                ...record,
+                location: {
+                    ...record?.location,
+                    folderId
+                }
+            });
+
             /**
              * We need to get the published and latest records, so we can update the Elasticsearch.
              */
@@ -685,10 +664,7 @@ export const createEntriesStorageOperations = (
             }
         }
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
             dataLoaders.clearAll({
                 model
             });
@@ -743,27 +719,27 @@ export const createEntriesStorageOperations = (
         if (esItems.length === 0) {
             return;
         }
-        const esUpdateItems: BatchWriteItem[] = [];
-        for (const item of esItems) {
-            esUpdateItems.push(
-                esEntity.putBatch({
-                    ...item,
-                    data: await compress(plugins, {
-                        ...item.data,
-                        location: {
-                            ...item.data?.location,
-                            folderId
-                        }
-                    })
+
+        const elasticsearchEntityBatch = createEntityWriteBatch({
+            entity: esEntity,
+            put: await Promise.all(
+                esItems.map(async item => {
+                    return {
+                        ...item,
+                        data: await compress(plugins, {
+                            ...item.data,
+                            location: {
+                                ...item.data?.location,
+                                folderId
+                            }
+                        })
+                    };
                 })
-            );
-        }
+            )
+        });
 
         try {
-            await batchWriteAll({
-                table: esEntity.table,
-                items: esUpdateItems
-            });
+            await elasticsearchEntityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not move entry DynamoDB Elasticsearch records.",
@@ -820,18 +796,20 @@ export const createEntriesStorageOperations = (
          */
         let latestRecord: CmsEntry | undefined = undefined;
         let publishedRecord: CmsEntry | undefined = undefined;
-        const items: BatchWriteItem[] = [];
+
+        const entityBatch = createEntityWriteBatch({
+            entity
+        });
 
         for (const record of records) {
-            items.push(
-                entity.putBatch({
-                    ...record,
-                    ...updatedEntryMetaFields,
-                    wbyDeleted: storageEntry.wbyDeleted,
-                    location: storageEntry.location,
-                    binOriginalFolderId: storageEntry.binOriginalFolderId
-                })
-            );
+            entityBatch.put({
+                ...record,
+                ...updatedEntryMetaFields,
+                wbyDeleted: storageEntry.wbyDeleted,
+                location: storageEntry.location,
+                binOriginalFolderId: storageEntry.binOriginalFolderId
+            });
+
             /**
              * We need to get the published and latest records, so we can update the Elasticsearch.
              */
@@ -846,10 +824,8 @@ export const createEntriesStorageOperations = (
          * We write the records back to the primary DynamoDB table.
          */
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
+
             dataLoaders.clearAll({
                 model
             });
@@ -915,30 +891,28 @@ export const createEntriesStorageOperations = (
         /**
          * We update all ES records with data received.
          */
-        const esUpdateItems: BatchWriteItem[] = [];
+        const elasticsearchEntityBatch = createEntityWriteBatch({
+            entity: esEntity
+        });
+
         for (const item of esItems) {
-            esUpdateItems.push(
-                esEntity.putBatch({
-                    ...item,
-                    data: await compress(plugins, {
-                        ...item.data,
-                        ...updatedEntryMetaFields,
-                        wbyDeleted: entry.wbyDeleted,
-                        location: entry.location,
-                        binOriginalFolderId: entry.binOriginalFolderId
-                    })
+            elasticsearchEntityBatch.put({
+                ...item,
+                data: await compress(plugins, {
+                    ...item.data,
+                    ...updatedEntryMetaFields,
+                    wbyDeleted: entry.wbyDeleted,
+                    location: entry.location,
+                    binOriginalFolderId: entry.binOriginalFolderId
                 })
-            );
+            });
         }
 
         /**
          * We write the records back to the primary DynamoDB Elasticsearch table.
          */
         try {
-            await batchWriteAll({
-                table: esEntity.table,
-                items: esUpdateItems
-            });
+            await elasticsearchEntityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message ||
@@ -1000,18 +974,20 @@ export const createEntriesStorageOperations = (
          */
         let latestRecord: CmsEntry | undefined = undefined;
         let publishedRecord: CmsEntry | undefined = undefined;
-        const items: BatchWriteItem[] = [];
+
+        const entityBatch = createEntityWriteBatch({
+            entity
+        });
 
         for (const record of records) {
-            items.push(
-                entity.putBatch({
-                    ...record,
-                    ...updatedEntryMetaFields,
-                    wbyDeleted: storageEntry.wbyDeleted,
-                    location: storageEntry.location,
-                    binOriginalFolderId: storageEntry.binOriginalFolderId
-                })
-            );
+            entityBatch.put({
+                ...record,
+                ...updatedEntryMetaFields,
+                wbyDeleted: storageEntry.wbyDeleted,
+                location: storageEntry.location,
+                binOriginalFolderId: storageEntry.binOriginalFolderId
+            });
+
             /**
              * We need to get the published and latest records, so we can update the Elasticsearch.
              */
@@ -1026,10 +1002,8 @@ export const createEntriesStorageOperations = (
          * We write the records back to the primary DynamoDB table.
          */
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
+
             dataLoaders.clearAll({
                 model
             });
@@ -1092,30 +1066,27 @@ export const createEntriesStorageOperations = (
         /**
          * We update all ES records with data received.
          */
-        const esUpdateItems: BatchWriteItem[] = [];
+        const elasticsearchEntityBatch = createEntityWriteBatch({
+            entity: esEntity
+        });
         for (const item of esItems) {
-            esUpdateItems.push(
-                esEntity.putBatch({
-                    ...item,
-                    data: await compress(plugins, {
-                        ...item.data,
-                        ...updatedEntryMetaFields,
-                        wbyDeleted: entry.wbyDeleted,
-                        location: entry.location,
-                        binOriginalFolderId: entry.binOriginalFolderId
-                    })
+            elasticsearchEntityBatch.put({
+                ...item,
+                data: await compress(plugins, {
+                    ...item.data,
+                    ...updatedEntryMetaFields,
+                    wbyDeleted: entry.wbyDeleted,
+                    location: entry.location,
+                    binOriginalFolderId: entry.binOriginalFolderId
                 })
-            );
+            });
         }
 
         /**
          * We write the records back to the primary DynamoDB Elasticsearch table.
          */
         try {
-            await batchWriteAll({
-                table: esEntity.table,
-                items: esUpdateItems
-            });
+            await elasticsearchEntityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not restore entry records from DynamoDB Elasticsearch table.",
@@ -1158,25 +1129,29 @@ export const createEntriesStorageOperations = (
             }
         });
 
-        const deleteItems = items.map(item => {
-            return entity.deleteBatch({
-                PK: item.PK,
-                SK: item.SK
-            });
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            delete: items.map(item => {
+                return {
+                    PK: item.PK,
+                    SK: item.SK
+                };
+            })
         });
 
-        const deleteEsItems = esItems.map(item => {
-            return esEntity.deleteBatch({
-                PK: item.PK,
-                SK: item.SK
-            });
+        const elasticsearchEntityBatch = createEntityWriteBatch({
+            entity: esEntity,
+            delete: esItems.map(item => {
+                return {
+                    PK: item.PK,
+                    SK: item.SK
+                };
+            })
         });
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items: deleteItems
-            });
+            await entityBatch.execute();
+
             dataLoaders.clearAll({
                 model
             });
@@ -1192,10 +1167,7 @@ export const createEntriesStorageOperations = (
         }
 
         try {
-            await batchWriteAll({
-                table: esEntity.table,
-                items: deleteEsItems
-            });
+            await elasticsearchEntityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not destroy entry records from DynamoDB Elasticsearch table.",
@@ -1234,34 +1206,33 @@ export const createEntriesStorageOperations = (
         /**
          * We need to delete all existing records of the given entry revision.
          */
-        const items = [
-            /**
-             * Delete records of given entry revision.
-             */
-            entity.deleteBatch({
-                PK: partitionKey,
-                SK: createRevisionSortKey(entry)
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            delete: [
+                {
+                    PK: partitionKey,
+                    SK: createRevisionSortKey(entry)
+                }
+            ]
+        });
 
-        const esItems: BatchWriteItem[] = [];
+        const elasticsearchEntityBatch = createEntityWriteBatch({
+            entity: esEntity
+        });
 
         /**
          * If revision we are deleting is the published one as well, we need to delete those records as well.
          */
         if (publishedStorageEntry?.id === entry.id) {
-            items.push(
-                entity.deleteBatch({
-                    PK: partitionKey,
-                    SK: createPublishedSortKey()
-                })
-            );
-            esItems.push(
-                esEntity.deleteBatch({
-                    PK: partitionKey,
-                    SK: createPublishedSortKey()
-                })
-            );
+            entityBatch.delete({
+                PK: partitionKey,
+                SK: createPublishedSortKey()
+            });
+
+            elasticsearchEntityBatch.delete({
+                PK: partitionKey,
+                SK: createPublishedSortKey()
+            });
         }
 
         if (latestEntry && initialLatestStorageEntry) {
@@ -1273,31 +1244,27 @@ export const createEntriesStorageOperations = (
             /**
              * In the end we need to set the new latest entry.
              */
-            items.push(
-                entity.putBatch({
-                    ...latestStorageEntry,
-                    PK: partitionKey,
-                    SK: createLatestSortKey(),
-                    TYPE: createLatestRecordType()
-                })
-            );
+            entityBatch.put({
+                ...latestStorageEntry,
+                PK: partitionKey,
+                SK: createLatestSortKey(),
+                TYPE: createLatestRecordType()
+            });
 
             /**
              * Also perform an update on the actual revision. This is needed
              * because of updates on the entry-level meta fields.
              */
-            items.push(
-                entity.putBatch({
-                    ...latestStorageEntry,
-                    PK: createPartitionKey({
-                        id: initialLatestStorageEntry.id,
-                        locale: model.locale,
-                        tenant: model.tenant
-                    }),
-                    SK: createRevisionSortKey(initialLatestStorageEntry),
-                    TYPE: createRecordType()
-                })
-            );
+            entityBatch.put({
+                ...latestStorageEntry,
+                PK: createPartitionKey({
+                    id: initialLatestStorageEntry.id,
+                    locale: model.locale,
+                    tenant: model.tenant
+                }),
+                SK: createRevisionSortKey(initialLatestStorageEntry),
+                TYPE: createRecordType()
+            });
 
             const latestTransformer = createTransformer({
                 plugins,
@@ -1307,21 +1274,16 @@ export const createEntriesStorageOperations = (
             });
 
             const esLatestData = await latestTransformer.getElasticsearchLatestEntryData();
-            esItems.push(
-                esEntity.putBatch({
-                    PK: partitionKey,
-                    SK: createLatestSortKey(),
-                    index,
-                    data: esLatestData
-                })
-            );
+            elasticsearchEntityBatch.put({
+                PK: partitionKey,
+                SK: createLatestSortKey(),
+                index,
+                data: esLatestData
+            });
         }
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
 
             dataLoaders.clearAll({
                 model
@@ -1339,15 +1301,8 @@ export const createEntriesStorageOperations = (
             );
         }
 
-        if (esItems.length === 0) {
-            return;
-        }
-
         try {
-            await batchWriteAll({
-                table: esEntity.table,
-                items: esItems
-            });
+            await elasticsearchEntityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message ||
@@ -1379,82 +1334,74 @@ export const createEntriesStorageOperations = (
         /**
          * Then we need to construct the queries for all the revisions and entries.
          */
-        const items: Record<string, WriteRequest>[] = [];
-        const esItems: Record<string, WriteRequest>[] = [];
+
+        const entityBatch = createEntityWriteBatch({
+            entity
+        });
+        const elasticsearchEntityBatch = createEntityWriteBatch({
+            entity: esEntity
+        });
         for (const id of entries) {
             /**
              * Latest item.
              */
-            items.push(
-                entity.deleteBatch({
-                    PK: createPartitionKey({
-                        id,
-                        locale: model.locale,
-                        tenant: model.tenant
-                    }),
-                    SK: "L"
-                })
-            );
-            esItems.push(
-                esEntity.deleteBatch({
-                    PK: createPartitionKey({
-                        id,
-                        locale: model.locale,
-                        tenant: model.tenant
-                    }),
-                    SK: "L"
-                })
-            );
+            entityBatch.delete({
+                PK: createPartitionKey({
+                    id,
+                    locale: model.locale,
+                    tenant: model.tenant
+                }),
+                SK: "L"
+            });
+
+            elasticsearchEntityBatch.delete({
+                PK: createPartitionKey({
+                    id,
+                    locale: model.locale,
+                    tenant: model.tenant
+                }),
+                SK: "L"
+            });
+
             /**
              * Published item.
              */
-            items.push(
-                entity.deleteBatch({
-                    PK: createPartitionKey({
-                        id,
-                        locale: model.locale,
-                        tenant: model.tenant
-                    }),
-                    SK: "P"
-                })
-            );
-            esItems.push(
-                esEntity.deleteBatch({
-                    PK: createPartitionKey({
-                        id,
-                        locale: model.locale,
-                        tenant: model.tenant
-                    }),
-                    SK: "P"
-                })
-            );
+            entityBatch.delete({
+                PK: createPartitionKey({
+                    id,
+                    locale: model.locale,
+                    tenant: model.tenant
+                }),
+                SK: "P"
+            });
+
+            elasticsearchEntityBatch.delete({
+                PK: createPartitionKey({
+                    id,
+                    locale: model.locale,
+                    tenant: model.tenant
+                }),
+                SK: "P"
+            });
         }
         /**
          * Exact revisions of all the entries
          */
         for (const revision of revisions) {
-            items.push(
-                entity.deleteBatch({
-                    PK: createPartitionKey({
-                        id: revision.id,
-                        locale: model.locale,
-                        tenant: model.tenant
-                    }),
-                    SK: createRevisionSortKey({
-                        version: revision.version
-                    })
+            entityBatch.delete({
+                PK: createPartitionKey({
+                    id: revision.id,
+                    locale: model.locale,
+                    tenant: model.tenant
+                }),
+                SK: createRevisionSortKey({
+                    version: revision.version
                 })
-            );
+            });
         }
 
-        await batchWriteAll({
-            table: entity.table,
-            items
-        });
-        await batchWriteAll({
-            table: esEntity.table,
-            items: esItems
-        });
+        await entityBatch.execute();
+        await elasticsearchEntityBatch.execute();
     };
 
     const list: CmsEntryStorageOperations["list"] = async (initialModel, params) => {
@@ -1641,19 +1588,25 @@ export const createEntriesStorageOperations = (
         });
 
         // 1. Update REV# and P records with new data.
-        const items = [
-            entity.putBatch({
-                ...storageEntry,
-                ...revisionKeys,
-                TYPE: createRecordType()
-            }),
-            entity.putBatch({
-                ...storageEntry,
-                ...publishedKeys,
-                TYPE: createPublishedRecordType()
-            })
-        ];
-        const esItems: BatchWriteItem[] = [];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...storageEntry,
+                    ...revisionKeys,
+                    TYPE: createRecordType()
+                },
+                {
+                    ...storageEntry,
+                    ...publishedKeys,
+                    TYPE: createPublishedRecordType()
+                }
+            ]
+        });
+
+        const elasticsearchEntityBatch = createEntityWriteBatch({
+            entity: esEntity
+        });
 
         const { index: esIndex } = configurations.es({
             model
@@ -1666,12 +1619,10 @@ export const createEntriesStorageOperations = (
 
         if (publishingLatestRevision) {
             // 2.1 If we're publishing the latest revision, we first need to update the L record.
-            items.push(
-                entity.putBatch({
-                    ...storageEntry,
-                    ...latestKeys
-                })
-            );
+            entityBatch.put({
+                ...storageEntry,
+                ...latestKeys
+            });
 
             // 2.2 Additionally, if we have a previously published entry, we need to mark it as unpublished.
             //     Note that we need to take re-publishing into account (same published revision being
@@ -1680,18 +1631,16 @@ export const createEntriesStorageOperations = (
             if (publishedStorageEntry) {
                 const isRepublishing = publishedStorageEntry.id === entry.id;
                 if (!isRepublishing) {
-                    items.push(
-                        /**
-                         * Update currently published entry (unpublish it)
-                         */
-                        entity.putBatch({
-                            ...publishedStorageEntry,
-                            status: CONTENT_ENTRY_STATUS.UNPUBLISHED,
-                            TYPE: createRecordType(),
-                            PK: createPartitionKey(publishedStorageEntry),
-                            SK: createRevisionSortKey(publishedStorageEntry)
-                        })
-                    );
+                    /**
+                     * Update currently published entry (unpublish it)
+                     */
+                    entityBatch.put({
+                        ...publishedStorageEntry,
+                        status: CONTENT_ENTRY_STATUS.UNPUBLISHED,
+                        TYPE: createRecordType(),
+                        PK: createPartitionKey(publishedStorageEntry),
+                        SK: createRevisionSortKey(publishedStorageEntry)
+                    });
                 }
             }
         } else {
@@ -1716,24 +1665,20 @@ export const createEntriesStorageOperations = (
                 status: latestRevisionStatus
             };
 
-            items.push(
-                entity.putBatch({
-                    ...latestStorageEntryFields,
-                    PK: createPartitionKey(latestStorageEntry),
-                    SK: createLatestSortKey(),
-                    TYPE: createLatestRecordType()
-                })
-            );
+            entityBatch.put({
+                ...latestStorageEntryFields,
+                PK: createPartitionKey(latestStorageEntry),
+                SK: createLatestSortKey(),
+                TYPE: createLatestRecordType()
+            });
 
             // 2.5 Update REV# record.
-            items.push(
-                entity.putBatch({
-                    ...latestStorageEntryFields,
-                    PK: createPartitionKey(latestStorageEntry),
-                    SK: createRevisionSortKey(latestStorageEntry),
-                    TYPE: createRecordType()
-                })
-            );
+            entityBatch.put({
+                ...latestStorageEntryFields,
+                PK: createPartitionKey(latestStorageEntry),
+                SK: createRevisionSortKey(latestStorageEntry),
+                TYPE: createRecordType()
+            });
 
             // 2.6 Additionally, if we have a previously published entry, we need to mark it as unpublished.
             //     Note that we need to take re-publishing into account (same published revision being
@@ -1745,15 +1690,13 @@ export const createEntriesStorageOperations = (
                     publishedRevisionId !== latestStorageEntry.id;
 
                 if (!isRepublishing && publishedRevisionDifferentFromLatest) {
-                    items.push(
-                        entity.putBatch({
-                            ...publishedStorageEntry,
-                            PK: createPartitionKey(publishedStorageEntry),
-                            SK: createRevisionSortKey(publishedStorageEntry),
-                            TYPE: createRecordType(),
-                            status: CONTENT_ENTRY_STATUS.UNPUBLISHED
-                        })
-                    );
+                    entityBatch.put({
+                        ...publishedStorageEntry,
+                        PK: createPartitionKey(publishedStorageEntry),
+                        SK: createRevisionSortKey(publishedStorageEntry),
+                        TYPE: createRecordType(),
+                        status: CONTENT_ENTRY_STATUS.UNPUBLISHED
+                    });
                 }
             }
         }
@@ -1764,13 +1707,11 @@ export const createEntriesStorageOperations = (
          * Update the published revision entry in ES.
          */
         const esPublishedData = await transformer.getElasticsearchPublishedEntryData();
-        esItems.push(
-            esEntity.putBatch({
-                ...publishedKeys,
-                index: esIndex,
-                data: esPublishedData
-            })
-        );
+        elasticsearchEntityBatch.put({
+            ...publishedKeys,
+            index: esIndex,
+            data: esPublishedData
+        });
 
         /**
          * Need to decompress the data from Elasticsearch DynamoDB table.
@@ -1797,14 +1738,12 @@ export const createEntriesStorageOperations = (
                 }
             });
 
-            esItems.push(
-                esEntity.putBatch({
-                    index: esIndex,
-                    PK: createPartitionKey(latestEsEntryDataDecompressed),
-                    SK: createLatestSortKey(),
-                    data: await latestTransformer.getElasticsearchLatestEntryData()
-                })
-            );
+            elasticsearchEntityBatch.put({
+                index: esIndex,
+                PK: createPartitionKey(latestEsEntryDataDecompressed),
+                SK: createLatestSortKey(),
+                data: await latestTransformer.getElasticsearchLatestEntryData()
+            });
         } else {
             const updatedEntryLevelMetaFields = pickEntryMetaFields(
                 entry,
@@ -1836,13 +1775,11 @@ export const createEntriesStorageOperations = (
                     status: latestRevisionStatus
                 });
 
-                esItems.push(
-                    esEntity.putBatch({
-                        ...latestKeys,
-                        index: esIndex,
-                        data: updatedLatestEntry
-                    })
-                );
+                elasticsearchEntityBatch.put({
+                    ...latestKeys,
+                    index: esIndex,
+                    data: updatedLatestEntry
+                });
             }
         }
 
@@ -1850,10 +1787,8 @@ export const createEntriesStorageOperations = (
          * Finally, execute regular table batch.
          */
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
+
             dataLoaders.clearAll({
                 model
             });
@@ -1873,10 +1808,7 @@ export const createEntriesStorageOperations = (
          * And Elasticsearch table batch.
          */
         try {
-            await batchWriteAll({
-                table: esEntity.table,
-                items: esItems
-            });
+            await elasticsearchEntityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message ||
@@ -1919,25 +1851,34 @@ export const createEntriesStorageOperations = (
             tenant: model.tenant
         });
 
-        const items = [
-            entity.deleteBatch({
-                PK: partitionKey,
-                SK: createPublishedSortKey()
-            }),
-            entity.putBatch({
-                ...storageEntry,
-                PK: partitionKey,
-                SK: createRevisionSortKey(entry),
-                TYPE: createRecordType()
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...storageEntry,
+                    PK: partitionKey,
+                    SK: createRevisionSortKey(entry),
+                    TYPE: createRecordType()
+                }
+            ],
+            delete: [
+                {
+                    PK: partitionKey,
+                    SK: createPublishedSortKey()
+                }
+            ]
+        });
+
+        const elasticsearchEntityBatch = createEntityWriteBatch({
+            entity: esEntity,
+            delete: [
+                {
+                    PK: partitionKey,
+                    SK: createPublishedSortKey()
+                }
+            ]
+        });
 
-        const esItems: BatchWriteItem[] = [
-            esEntity.deleteBatch({
-                PK: partitionKey,
-                SK: createPublishedSortKey()
-            })
-        ];
         /**
          * If we are unpublishing the latest revision, let's also update the latest revision entry's status in both DynamoDB tables.
          */
@@ -1946,34 +1887,28 @@ export const createEntriesStorageOperations = (
                 model
             });
 
-            items.push(
-                entity.putBatch({
-                    ...storageEntry,
-                    PK: partitionKey,
-                    SK: createLatestSortKey(),
-                    TYPE: createLatestRecordType()
-                })
-            );
+            entityBatch.put({
+                ...storageEntry,
+                PK: partitionKey,
+                SK: createLatestSortKey(),
+                TYPE: createLatestRecordType()
+            });
 
             const esLatestData = await transformer.getElasticsearchLatestEntryData();
-            esItems.push(
-                esEntity.putBatch({
-                    PK: partitionKey,
-                    SK: createLatestSortKey(),
-                    index,
-                    data: esLatestData
-                })
-            );
+            elasticsearchEntityBatch.put({
+                PK: partitionKey,
+                SK: createLatestSortKey(),
+                index,
+                data: esLatestData
+            });
         }
 
         /**
          * Finally, execute regular table batch.
          */
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
+
             dataLoaders.clearAll({
                 model
             });
@@ -1991,10 +1926,7 @@ export const createEntriesStorageOperations = (
          * And Elasticsearch table batch.
          */
         try {
-            await batchWriteAll({
-                table: esEntity.table,
-                items: esItems
-            });
+            await elasticsearchEntityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message ||
diff --git a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts
index b35994d54f1..fc13f4270d4 100644
--- a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts
+++ b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts
@@ -1,5 +1,5 @@
 import DataLoader from "dataloader";
-import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead";
+import { batchReadAll } from "@webiny/db-dynamodb";
 import { cleanupItems } from "@webiny/db-dynamodb/utils/cleanup";
 import { CmsStorageEntry } from "@webiny/api-headless-cms/types";
 import { createBatchScheduleFn } from "./createBatchScheduleFn";
diff --git a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts
index 062ebae0557..d3b97b70eae 100644
--- a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts
+++ b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts
@@ -1,5 +1,5 @@
 import DataLoader from "dataloader";
-import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead";
+import { batchReadAll } from "@webiny/db-dynamodb";
 import { cleanupItems } from "@webiny/db-dynamodb/utils/cleanup";
 import { CmsStorageEntry } from "@webiny/api-headless-cms/types";
 import { createPartitionKey, createPublishedSortKey } from "~/operations/entry/keys";
diff --git a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getRevisionById.ts b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getRevisionById.ts
index 7c020d82574..04b5783ace6 100644
--- a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getRevisionById.ts
+++ b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getRevisionById.ts
@@ -1,5 +1,5 @@
 import DataLoader from "dataloader";
-import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead";
+import { batchReadAll } from "@webiny/db-dynamodb";
 import { CmsStorageEntry } from "@webiny/api-headless-cms/types";
 import { cleanupItems } from "@webiny/db-dynamodb/utils/cleanup";
 import { createPartitionKey, createRevisionSortKey } from "~/operations/entry/keys";
diff --git a/packages/api-headless-cms-ddb/src/operations/entry/index.ts b/packages/api-headless-cms-ddb/src/operations/entry/index.ts
index 9c5f84e5b90..5600f97a037 100644
--- a/packages/api-headless-cms-ddb/src/operations/entry/index.ts
+++ b/packages/api-headless-cms-ddb/src/operations/entry/index.ts
@@ -1,15 +1,15 @@
 import WebinyError from "@webiny/error";
 import { DataLoadersHandler } from "./dataLoaders";
-import {
+import type {
     CmsEntry,
     CmsEntryListWhere,
     CmsEntryUniqueValue,
     CmsModel,
     CmsStorageEntry,
-    CONTENT_ENTRY_STATUS,
     StorageOperationsCmsModel
 } from "@webiny/api-headless-cms/types";
-import { Entity } from "@webiny/db-dynamodb/toolbox";
+import { CONTENT_ENTRY_STATUS } from "@webiny/api-headless-cms/types";
+import type { Entity } from "@webiny/db-dynamodb/toolbox";
 import {
     createGSIPartitionKey,
     createGSISortKey,
@@ -18,24 +18,23 @@ import {
     createPublishedSortKey,
     createRevisionSortKey
 } from "~/operations/entry/keys";
-import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite";
 import {
-    DbItem,
+    cleanupItem,
+    cleanupItems,
+    createEntityWriteBatch,
     queryAll,
     QueryAllParams,
     queryOne,
     QueryOneParams
-} from "@webiny/db-dynamodb/utils/query";
-import { cleanupItem, cleanupItems } from "@webiny/db-dynamodb/utils/cleanup";
-import { PluginsContainer } from "@webiny/plugins";
+} from "@webiny/db-dynamodb";
+import type { PluginsContainer } from "@webiny/plugins";
 import { decodeCursor, encodeCursor } from "@webiny/utils/cursor";
 import { zeroPad } from "@webiny/utils/zeroPad";
 import { StorageOperationsCmsModelPlugin, StorageTransformPlugin } from "@webiny/api-headless-cms";
-import { FilterItemFromStorage } from "./filtering/types";
+import type { FilterItemFromStorage } from "./filtering/types";
 import { createFields } from "~/operations/entry/filtering/createFields";
 import { filter, sort } from "~/operations/entry/filtering";
-import { WriteRequest } from "@webiny/aws-sdk/client-dynamodb";
-import { CmsEntryStorageOperations } from "~/types";
+import type { CmsEntryStorageOperations } from "~/types";
 import {
     isDeletedEntryMetaField,
     isEntryLevelEntryMetaField,
@@ -167,49 +166,47 @@ export const createEntriesStorageOperations = (
          *  - create new main entry item
          *  - create new or update the latest entry item
          */
-        const items = [
-            entity.putBatch({
-                ...storageEntry,
-                locked,
-                PK: partitionKey,
-                SK: createRevisionSortKey(entry),
-                TYPE: createType(),
-                GSI1_PK: createGSIPartitionKey(model, "A"),
-                GSI1_SK: createGSISortKey(storageEntry)
-            }),
-            entity.putBatch({
-                ...storageEntry,
-                locked,
-                PK: partitionKey,
-                SK: createLatestSortKey(),
-                TYPE: createLatestType(),
-                GSI1_PK: createGSIPartitionKey(model, "L"),
-                GSI1_SK: createGSISortKey(storageEntry)
-            })
-        ];
-
-        /**
-         * We need to create published entry if
-         */
-        if (isPublished) {
-            items.push(
-                entity.putBatch({
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
                     ...storageEntry,
                     locked,
                     PK: partitionKey,
-                    SK: createPublishedSortKey(),
+                    SK: createRevisionSortKey(entry),
+                    TYPE: createType(),
+                    GSI1_PK: createGSIPartitionKey(model, "A"),
+                    GSI1_SK: createGSISortKey(storageEntry)
+                },
+                {
+                    ...storageEntry,
+                    locked,
+                    PK: partitionKey,
+                    SK: createLatestSortKey(),
                     TYPE: createLatestType(),
-                    GSI1_PK: createGSIPartitionKey(model, "P"),
+                    GSI1_PK: createGSIPartitionKey(model, "L"),
                     GSI1_SK: createGSISortKey(storageEntry)
-                })
-            );
+                }
+            ]
+        });
+
+        /**
+         * We need to create published entry if
+         */
+        if (isPublished) {
+            entityBatch.put({
+                ...storageEntry,
+                locked,
+                PK: partitionKey,
+                SK: createPublishedSortKey(),
+                TYPE: createLatestType(),
+                GSI1_PK: createGSIPartitionKey(model, "P"),
+                GSI1_SK: createGSISortKey(storageEntry)
+            });
         }
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
             dataLoaders.clearAll({
                 model
             });
@@ -253,37 +250,38 @@ export const createEntriesStorageOperations = (
          *      - update the published entry item to the current one
          *      - unpublish previously published revision (if any)
          */
-        const items = [
-            entity.putBatch({
-                ...storageEntry,
-                PK: partitionKey,
-                SK: createRevisionSortKey(storageEntry),
-                TYPE: createType(),
-                GSI1_PK: createGSIPartitionKey(model, "A"),
-                GSI1_SK: createGSISortKey(storageEntry)
-            }),
-            entity.putBatch({
-                ...storageEntry,
-                PK: partitionKey,
-                SK: createLatestSortKey(),
-                TYPE: createLatestType(),
-                GSI1_PK: createGSIPartitionKey(model, "L"),
-                GSI1_SK: createGSISortKey(storageEntry)
-            })
-        ];
-
-        const isPublished = entry.status === "published";
-        if (isPublished) {
-            items.push(
-                entity.putBatch({
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
                     ...storageEntry,
                     PK: partitionKey,
-                    SK: createPublishedSortKey(),
-                    TYPE: createPublishedType(),
-                    GSI1_PK: createGSIPartitionKey(model, "P"),
+                    SK: createRevisionSortKey(storageEntry),
+                    TYPE: createType(),
+                    GSI1_PK: createGSIPartitionKey(model, "A"),
                     GSI1_SK: createGSISortKey(storageEntry)
-                })
-            );
+                },
+                {
+                    ...storageEntry,
+                    PK: partitionKey,
+                    SK: createLatestSortKey(),
+                    TYPE: createLatestType(),
+                    GSI1_PK: createGSIPartitionKey(model, "L"),
+                    GSI1_SK: createGSISortKey(storageEntry)
+                }
+            ]
+        });
+
+        const isPublished = entry.status === "published";
+        if (isPublished) {
+            entityBatch.put({
+                ...storageEntry,
+                PK: partitionKey,
+                SK: createPublishedSortKey(),
+                TYPE: createPublishedType(),
+                GSI1_PK: createGSIPartitionKey(model, "P"),
+                GSI1_SK: createGSISortKey(storageEntry)
+            });
 
             // Unpublish previously published revision (if any).
             const [publishedRevisionStorageEntry] = await dataLoaders.getPublishedRevisionByEntryId(
@@ -294,25 +292,20 @@ export const createEntriesStorageOperations = (
             );
 
             if (publishedRevisionStorageEntry) {
-                items.push(
-                    entity.putBatch({
-                        ...publishedRevisionStorageEntry,
-                        PK: partitionKey,
-                        SK: createRevisionSortKey(publishedRevisionStorageEntry),
-                        TYPE: createType(),
-                        status: CONTENT_ENTRY_STATUS.UNPUBLISHED,
-                        GSI1_PK: createGSIPartitionKey(model, "A"),
-                        GSI1_SK: createGSISortKey(publishedRevisionStorageEntry)
-                    })
-                );
+                entityBatch.put({
+                    ...publishedRevisionStorageEntry,
+                    PK: partitionKey,
+                    SK: createRevisionSortKey(publishedRevisionStorageEntry),
+                    TYPE: createType(),
+                    status: CONTENT_ENTRY_STATUS.UNPUBLISHED,
+                    GSI1_PK: createGSIPartitionKey(model, "A"),
+                    GSI1_SK: createGSISortKey(publishedRevisionStorageEntry)
+                });
             }
         }
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
             dataLoaders.clearAll({
                 model
             });
@@ -346,8 +339,6 @@ export const createEntriesStorageOperations = (
         const isPublished = entry.status === "published";
         const locked = isPublished ? true : entry.locked;
 
-        const items = [];
-
         const storageEntry = convertToStorageEntry({
             model,
             storageEntry: initialStorageEntry
@@ -357,30 +348,32 @@ export const createEntriesStorageOperations = (
          *  - update the current entry
          *  - update the latest entry if the current entry is the latest one
          */
-        items.push(
-            entity.putBatch({
-                ...storageEntry,
-                locked,
-                PK: partitionKey,
-                SK: createRevisionSortKey(storageEntry),
-                TYPE: createType(),
-                GSI1_PK: createGSIPartitionKey(model, "A"),
-                GSI1_SK: createGSISortKey(storageEntry)
-            })
-        );
 
-        if (isPublished) {
-            items.push(
-                entity.putBatch({
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
                     ...storageEntry,
                     locked,
                     PK: partitionKey,
-                    SK: createPublishedSortKey(),
-                    TYPE: createPublishedType(),
-                    GSI1_PK: createGSIPartitionKey(model, "P"),
+                    SK: createRevisionSortKey(storageEntry),
+                    TYPE: createType(),
+                    GSI1_PK: createGSIPartitionKey(model, "A"),
                     GSI1_SK: createGSISortKey(storageEntry)
-                })
-            );
+                }
+            ]
+        });
+
+        if (isPublished) {
+            entityBatch.put({
+                ...storageEntry,
+                locked,
+                PK: partitionKey,
+                SK: createPublishedSortKey(),
+                TYPE: createPublishedType(),
+                GSI1_PK: createGSIPartitionKey(model, "P"),
+                GSI1_SK: createGSISortKey(storageEntry)
+            });
         }
 
         /**
@@ -391,17 +384,15 @@ export const createEntriesStorageOperations = (
         if (latestStorageEntry) {
             const updatingLatestRevision = latestStorageEntry.id === entry.id;
             if (updatingLatestRevision) {
-                items.push(
-                    entity.putBatch({
-                        ...storageEntry,
-                        locked,
-                        PK: partitionKey,
-                        SK: createLatestSortKey(),
-                        TYPE: createLatestType(),
-                        GSI1_PK: createGSIPartitionKey(model, "L"),
-                        GSI1_SK: createGSISortKey(entry)
-                    })
-                );
+                entityBatch.put({
+                    ...storageEntry,
+                    locked,
+                    PK: partitionKey,
+                    SK: createLatestSortKey(),
+                    TYPE: createLatestType(),
+                    GSI1_PK: createGSIPartitionKey(model, "L"),
+                    GSI1_SK: createGSISortKey(entry)
+                });
             } else {
                 /**
                  * If not updating latest revision, we still want to update the latest revision's
@@ -417,37 +408,30 @@ export const createEntriesStorageOperations = (
                  * - one for the actual revision record
                  * - one for the latest record
                  */
-                items.push(
-                    entity.putBatch({
-                        ...latestStorageEntry,
-                        ...updatedEntryLevelMetaFields,
-                        PK: partitionKey,
-                        SK: createRevisionSortKey(latestStorageEntry),
-                        TYPE: createType(),
-                        GSI1_PK: createGSIPartitionKey(model, "A"),
-                        GSI1_SK: createGSISortKey(latestStorageEntry)
-                    })
-                );
+                entityBatch.put({
+                    ...latestStorageEntry,
+                    ...updatedEntryLevelMetaFields,
+                    PK: partitionKey,
+                    SK: createRevisionSortKey(latestStorageEntry),
+                    TYPE: createType(),
+                    GSI1_PK: createGSIPartitionKey(model, "A"),
+                    GSI1_SK: createGSISortKey(latestStorageEntry)
+                });
 
-                items.push(
-                    entity.putBatch({
-                        ...latestStorageEntry,
-                        ...updatedEntryLevelMetaFields,
-                        PK: partitionKey,
-                        SK: createLatestSortKey(),
-                        TYPE: createLatestType(),
-                        GSI1_PK: createGSIPartitionKey(model, "L"),
-                        GSI1_SK: createGSISortKey(latestStorageEntry)
-                    })
-                );
+                entityBatch.put({
+                    ...latestStorageEntry,
+                    ...updatedEntryLevelMetaFields,
+                    PK: partitionKey,
+                    SK: createLatestSortKey(),
+                    TYPE: createLatestType(),
+                    GSI1_PK: createGSIPartitionKey(model, "L"),
+                    GSI1_SK: createGSISortKey(latestStorageEntry)
+                });
             }
         }
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
             dataLoaders.clearAll({
                 model
             });
@@ -490,23 +474,24 @@ export const createEntriesStorageOperations = (
         /**
          * Then create the batch writes for the DynamoDB, with the updated folderId.
          */
-        const items = records.map(item => {
-            return entity.putBatch({
-                ...item,
-                location: {
-                    ...item.location,
-                    folderId
-                }
-            });
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: records.map(item => {
+                return {
+                    ...item,
+                    location: {
+                        ...item.location,
+                        folderId
+                    }
+                };
+            })
         });
+
         /**
          * And finally write it...
          */
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw WebinyError.from(ex, {
                 message: "Could not move records to a new folder.",
@@ -518,7 +503,10 @@ export const createEntriesStorageOperations = (
         }
     };
 
-    const moveToBin: CmsEntryStorageOperations["moveToBin"] = async (initialModel, params) => {
+    const moveToBin: CmsEntryStorageOperations["moveToBin"] = async (
+        initialModel,
+        params
+    ): Promise<void> => {
         const { entry, storageEntry: initialStorageEntry } = params;
         const model = getStorageOperationsModel(initialModel);
 
@@ -537,9 +525,9 @@ export const createEntriesStorageOperations = (
             }
         };
 
-        let records: DbItem<CmsEntry>[] = [];
+        let records: Awaited<ReturnType<typeof queryAll<CmsEntry>>> = [];
         try {
-            records = await queryAll(queryAllParams);
+            records = await queryAll<CmsEntry>(queryAllParams);
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not load all records.",
@@ -550,6 +538,9 @@ export const createEntriesStorageOperations = (
                 }
             );
         }
+        if (records.length === 0) {
+            return;
+        }
 
         const storageEntry = convertToStorageEntry({
             model,
@@ -564,23 +555,23 @@ export const createEntriesStorageOperations = (
         /**
          * Then create the batch writes for the DynamoDB, with the updated data.
          */
-        const items = records.map(record => {
-            return entity.putBatch({
-                ...record,
-                ...updatedDeletedMetaFields,
-                wbyDeleted: storageEntry.wbyDeleted,
-                location: storageEntry.location,
-                binOriginalFolderId: storageEntry.binOriginalFolderId
-            });
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: records.map(record => {
+                return {
+                    ...record,
+                    ...updatedDeletedMetaFields,
+                    wbyDeleted: storageEntry.wbyDeleted,
+                    location: storageEntry.location,
+                    binOriginalFolderId: storageEntry.binOriginalFolderId
+                };
+            })
         });
         /**
          * And finally write it...
          */
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not move the entry to the bin.",
@@ -611,7 +602,7 @@ export const createEntriesStorageOperations = (
             }
         };
 
-        let records: DbItem<CmsEntry>[] = [];
+        let records: Awaited<ReturnType<typeof queryAll<CmsEntry>>> = [];
         try {
             records = await queryAll(queryAllParams);
         } catch (ex) {
@@ -624,18 +615,19 @@ export const createEntriesStorageOperations = (
                 }
             );
         }
-        const items = records.map(item => {
-            return entity.deleteBatch({
-                PK: item.PK,
-                SK: item.SK
-            });
+
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            delete: records.map(item => {
+                return {
+                    PK: item.PK,
+                    SK: item.SK
+                };
+            })
         });
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
             dataLoaders.clearAll({
                 model
             });
@@ -655,7 +647,7 @@ export const createEntriesStorageOperations = (
     const restoreFromBin: CmsEntryStorageOperations["restoreFromBin"] = async (
         initialModel,
         params
-    ) => {
+    ): Promise<CmsStorageEntry> => {
         const { entry, storageEntry: initialStorageEntry } = params;
         const model = getStorageOperationsModel(initialModel);
 
@@ -674,9 +666,9 @@ export const createEntriesStorageOperations = (
             }
         };
 
-        let records: DbItem<CmsEntry>[] = [];
+        let records: Awaited<ReturnType<typeof queryAll<CmsEntry>>> = [];
         try {
-            records = await queryAll(queryAllParams);
+            records = await queryAll<CmsEntry>(queryAllParams);
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not load all records.",
@@ -687,6 +679,9 @@ export const createEntriesStorageOperations = (
                 }
             );
         }
+        if (records.length === 0) {
+            return initialStorageEntry;
+        }
 
         const storageEntry = convertToStorageEntry({
             model,
@@ -701,23 +696,24 @@ export const createEntriesStorageOperations = (
             isRestoredEntryMetaField
         );
 
-        const items = records.map(record => {
-            return entity.putBatch({
-                ...record,
-                ...updatedRestoredMetaFields,
-                wbyDeleted: storageEntry.wbyDeleted,
-                location: storageEntry.location,
-                binOriginalFolderId: storageEntry.binOriginalFolderId
-            });
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: records.map(record => {
+                return {
+                    ...record,
+                    ...updatedRestoredMetaFields,
+                    wbyDeleted: storageEntry.wbyDeleted,
+                    location: storageEntry.location,
+                    binOriginalFolderId: storageEntry.binOriginalFolderId
+                };
+            })
         });
+
         /**
          * And finally write it...
          */
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
 
             dataLoaders.clearAll({
                 model
@@ -751,12 +747,15 @@ export const createEntriesStorageOperations = (
             tenant: model.tenant
         });
 
-        const items = [
-            entity.deleteBatch({
-                PK: partitionKey,
-                SK: createRevisionSortKey(entry)
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            delete: [
+                {
+                    PK: partitionKey,
+                    SK: createRevisionSortKey(entry)
+                }
+            ]
+        });
 
         const publishedStorageEntry = await getPublishedRevisionByEntryId(model, entry);
 
@@ -764,12 +763,10 @@ export const createEntriesStorageOperations = (
          * If revision we are deleting is the published one as well, we need to delete those records as well.
          */
         if (publishedStorageEntry && entry.id === publishedStorageEntry.id) {
-            items.push(
-                entity.deleteBatch({
-                    PK: partitionKey,
-                    SK: createPublishedSortKey()
-                })
-            );
+            entityBatch.delete({
+                PK: partitionKey,
+                SK: createPublishedSortKey()
+            });
         }
 
         if (initialLatestStorageEntry) {
@@ -777,35 +774,29 @@ export const createEntriesStorageOperations = (
                 storageEntry: initialLatestStorageEntry,
                 model
             });
-            items.push(
-                entity.putBatch({
-                    ...latestStorageEntry,
-                    PK: partitionKey,
-                    SK: createLatestSortKey(),
-                    TYPE: createLatestType(),
-                    GSI1_PK: createGSIPartitionKey(model, "L"),
-                    GSI1_SK: createGSISortKey(latestStorageEntry)
-                })
-            );
+            entityBatch.put({
+                ...latestStorageEntry,
+                PK: partitionKey,
+                SK: createLatestSortKey(),
+                TYPE: createLatestType(),
+                GSI1_PK: createGSIPartitionKey(model, "L"),
+                GSI1_SK: createGSISortKey(latestStorageEntry)
+            });
 
             // Do an update on the latest revision. We need to update the latest revision's
             // entry-level meta fields to match the previous revision's entry-level meta fields.
-            items.push(
-                entity.putBatch({
-                    ...latestStorageEntry,
-                    PK: partitionKey,
-                    SK: createRevisionSortKey(initialLatestStorageEntry),
-                    TYPE: createType(),
-                    GSI1_PK: createGSIPartitionKey(model, "A"),
-                    GSI1_SK: createGSISortKey(initialLatestStorageEntry)
-                })
-            );
+            entityBatch.put({
+                ...latestStorageEntry,
+                PK: partitionKey,
+                SK: createRevisionSortKey(initialLatestStorageEntry),
+                TYPE: createType(),
+                GSI1_PK: createGSIPartitionKey(model, "A"),
+                GSI1_SK: createGSISortKey(initialLatestStorageEntry)
+            });
         }
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            entityBatch.execute();
+
             dataLoaders.clearAll({
                 model
             });
@@ -834,57 +825,43 @@ export const createEntriesStorageOperations = (
         /**
          * Then we need to construct the queries for all the revisions and entries.
          */
-        const items: Record<string, WriteRequest>[] = [];
+
+        const entityBatch = createEntityWriteBatch({
+            entity
+        });
+
         for (const id of entries) {
-            /**
-             * Latest item.
-             */
-            items.push(
-                entity.deleteBatch({
-                    PK: createPartitionKey({
-                        id,
-                        locale: model.locale,
-                        tenant: model.tenant
-                    }),
-                    SK: "L"
-                })
-            );
-            /**
-             * Published item.
-             */
-            items.push(
-                entity.deleteBatch({
-                    PK: createPartitionKey({
-                        id,
-                        locale: model.locale,
-                        tenant: model.tenant
-                    }),
-                    SK: "P"
-                })
-            );
+            const partitionKey = createPartitionKey({
+                id,
+                locale: model.locale,
+                tenant: model.tenant
+            });
+            entityBatch.delete({
+                PK: partitionKey,
+                SK: "L"
+            });
+            entityBatch.delete({
+                PK: partitionKey,
+                SK: "P"
+            });
         }
         /**
          * Exact revisions of all the entries
          */
         for (const revision of revisions) {
-            items.push(
-                entity.deleteBatch({
-                    PK: createPartitionKey({
-                        id: revision.id,
-                        locale: model.locale,
-                        tenant: model.tenant
-                    }),
-                    SK: createRevisionSortKey({
-                        version: revision.version
-                    })
+            entityBatch.delete({
+                PK: createPartitionKey({
+                    id: revision.id,
+                    locale: model.locale,
+                    tenant: model.tenant
+                }),
+                SK: createRevisionSortKey({
+                    version: revision.version
                 })
-            );
+            });
         }
 
-        await batchWriteAll({
-            table: entity.table,
-            items
-        });
+        await entityBatch.execute();
     };
 
     const getLatestRevisionByEntryId: CmsEntryStorageOperations["getLatestRevisionByEntryId"] =
@@ -1239,24 +1216,27 @@ export const createEntriesStorageOperations = (
         });
 
         // 1. Update REV# and P records with new data.
-        const items = [
-            entity.putBatch({
-                ...storageEntry,
-                PK: partitionKey,
-                SK: createRevisionSortKey(entry),
-                TYPE: createType(),
-                GSI1_PK: createGSIPartitionKey(model, "A"),
-                GSI1_SK: createGSISortKey(entry)
-            }),
-            entity.putBatch({
-                ...storageEntry,
-                PK: partitionKey,
-                SK: createPublishedSortKey(),
-                TYPE: createPublishedType(),
-                GSI1_PK: createGSIPartitionKey(model, "P"),
-                GSI1_SK: createGSISortKey(entry)
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...storageEntry,
+                    PK: partitionKey,
+                    SK: createRevisionSortKey(entry),
+                    TYPE: createType(),
+                    GSI1_PK: createGSIPartitionKey(model, "A"),
+                    GSI1_SK: createGSISortKey(entry)
+                },
+                {
+                    ...storageEntry,
+                    PK: partitionKey,
+                    SK: createPublishedSortKey(),
+                    TYPE: createPublishedType(),
+                    GSI1_PK: createGSIPartitionKey(model, "P"),
+                    GSI1_SK: createGSISortKey(entry)
+                }
+            ]
+        });
 
         // 2. When it comes to the latest record, we need to perform a couple of different
         // updates, based on whether the entry being published is the latest revision or not.
@@ -1265,16 +1245,14 @@ export const createEntriesStorageOperations = (
 
         if (publishingLatestRevision) {
             // 2.1 If we're publishing the latest revision, we first need to update the L record.
-            items.push(
-                entity.putBatch({
-                    ...storageEntry,
-                    PK: partitionKey,
-                    SK: createLatestSortKey(),
-                    TYPE: createLatestType(),
-                    GSI1_PK: createGSIPartitionKey(model, "L"),
-                    GSI1_SK: createGSISortKey(entry)
-                })
-            );
+            entityBatch.put({
+                ...storageEntry,
+                PK: partitionKey,
+                SK: createLatestSortKey(),
+                TYPE: createLatestType(),
+                GSI1_PK: createGSIPartitionKey(model, "L"),
+                GSI1_SK: createGSISortKey(entry)
+            });
 
             // 2.2 Additionally, if we have a previously published entry, we need to mark it as unpublished.
             if (publishedRevisionId && publishedRevisionId !== entry.id) {
@@ -1283,17 +1261,15 @@ export const createEntriesStorageOperations = (
                     model
                 });
 
-                items.push(
-                    entity.putBatch({
-                        ...publishedStorageEntry,
-                        PK: partitionKey,
-                        SK: createRevisionSortKey(publishedStorageEntry),
-                        TYPE: createType(),
-                        status: CONTENT_ENTRY_STATUS.UNPUBLISHED,
-                        GSI1_PK: createGSIPartitionKey(model, "A"),
-                        GSI1_SK: createGSISortKey(publishedStorageEntry)
-                    })
-                );
+                entityBatch.put({
+                    ...publishedStorageEntry,
+                    PK: partitionKey,
+                    SK: createRevisionSortKey(publishedStorageEntry),
+                    TYPE: createType(),
+                    status: CONTENT_ENTRY_STATUS.UNPUBLISHED,
+                    GSI1_PK: createGSIPartitionKey(model, "A"),
+                    GSI1_SK: createGSISortKey(publishedStorageEntry)
+                });
             }
         } else {
             // 2.3 If the published revision is not the latest one, the situation is a bit
@@ -1322,28 +1298,24 @@ export const createEntriesStorageOperations = (
                 status: latestRevisionStatus
             };
 
-            items.push(
-                entity.putBatch({
-                    ...latestStorageEntryFields,
-                    PK: partitionKey,
-                    SK: createLatestSortKey(),
-                    TYPE: createLatestType(),
-                    GSI1_PK: createGSIPartitionKey(model, "L"),
-                    GSI1_SK: createGSISortKey(latestStorageEntry)
-                })
-            );
+            entityBatch.put({
+                ...latestStorageEntryFields,
+                PK: partitionKey,
+                SK: createLatestSortKey(),
+                TYPE: createLatestType(),
+                GSI1_PK: createGSIPartitionKey(model, "L"),
+                GSI1_SK: createGSISortKey(latestStorageEntry)
+            });
 
             // 2.3.2 Update REV# record.
-            items.push(
-                entity.putBatch({
-                    ...latestStorageEntryFields,
-                    PK: partitionKey,
-                    SK: createRevisionSortKey(latestStorageEntry),
-                    TYPE: createType(),
-                    GSI1_PK: createGSIPartitionKey(model, "A"),
-                    GSI1_SK: createGSISortKey(latestStorageEntry)
-                })
-            );
+            entityBatch.put({
+                ...latestStorageEntryFields,
+                PK: partitionKey,
+                SK: createRevisionSortKey(latestStorageEntry),
+                TYPE: createType(),
+                GSI1_PK: createGSIPartitionKey(model, "A"),
+                GSI1_SK: createGSISortKey(latestStorageEntry)
+            });
 
             // 2.3.3 Finally, if we got a published entry, but it wasn't the latest one, we need to take
             //    an extra step and mark it as unpublished.
@@ -1355,25 +1327,20 @@ export const createEntriesStorageOperations = (
                     model
                 });
 
-                items.push(
-                    entity.putBatch({
-                        ...publishedStorageEntry,
-                        PK: partitionKey,
-                        SK: createRevisionSortKey(publishedStorageEntry),
-                        TYPE: createType(),
-                        status: CONTENT_ENTRY_STATUS.UNPUBLISHED,
-                        GSI1_PK: createGSIPartitionKey(model, "A"),
-                        GSI1_SK: createGSISortKey(publishedStorageEntry)
-                    })
-                );
+                entityBatch.put({
+                    ...publishedStorageEntry,
+                    PK: partitionKey,
+                    SK: createRevisionSortKey(publishedStorageEntry),
+                    TYPE: createType(),
+                    status: CONTENT_ENTRY_STATUS.UNPUBLISHED,
+                    GSI1_PK: createGSIPartitionKey(model, "A"),
+                    GSI1_SK: createGSISortKey(publishedStorageEntry)
+                });
             }
         }
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
             dataLoaders.clearAll({
                 model
             });
@@ -1411,20 +1378,25 @@ export const createEntriesStorageOperations = (
          *  - update current entry revision with new data
          *  - update the latest entry status - if entry being unpublished is latest
          */
-        const items = [
-            entity.deleteBatch({
-                PK: partitionKey,
-                SK: createPublishedSortKey()
-            }),
-            entity.putBatch({
-                ...storageEntry,
-                PK: partitionKey,
-                SK: createRevisionSortKey(entry),
-                TYPE: createType(),
-                GSI1_PK: createGSIPartitionKey(model, "A"),
-                GSI1_SK: createGSISortKey(entry)
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            delete: [
+                {
+                    PK: partitionKey,
+                    SK: createPublishedSortKey()
+                }
+            ],
+            put: [
+                {
+                    ...storageEntry,
+                    PK: partitionKey,
+                    SK: createRevisionSortKey(entry),
+                    TYPE: createType(),
+                    GSI1_PK: createGSIPartitionKey(model, "A"),
+                    GSI1_SK: createGSISortKey(entry)
+                }
+            ]
+        });
 
         /**
          * We need the latest entry to see if something needs to be updated alongside the unpublishing one.
@@ -1434,16 +1406,14 @@ export const createEntriesStorageOperations = (
         if (initialLatestStorageEntry) {
             const unpublishingLatestRevision = entry.id === initialLatestStorageEntry.id;
             if (unpublishingLatestRevision) {
-                items.push(
-                    entity.putBatch({
-                        ...storageEntry,
-                        PK: partitionKey,
-                        SK: createLatestSortKey(),
-                        TYPE: createLatestType(),
-                        GSI1_PK: createGSIPartitionKey(model, "L"),
-                        GSI1_SK: createGSISortKey(entry)
-                    })
-                );
+                entityBatch.put({
+                    ...storageEntry,
+                    PK: partitionKey,
+                    SK: createLatestSortKey(),
+                    TYPE: createLatestType(),
+                    GSI1_PK: createGSIPartitionKey(model, "L"),
+                    GSI1_SK: createGSISortKey(entry)
+                });
             } else {
                 const latestStorageEntry = convertToStorageEntry({
                     storageEntry: initialLatestStorageEntry,
@@ -1458,38 +1428,31 @@ export const createEntriesStorageOperations = (
                 );
 
                 // 1. Update actual revision record.
-                items.push(
-                    entity.putBatch({
-                        ...latestStorageEntry,
-                        ...updatedEntryLevelMetaFields,
-                        PK: partitionKey,
-                        SK: createRevisionSortKey(latestStorageEntry),
-                        TYPE: createType(),
-                        GSI1_PK: createGSIPartitionKey(model, "A"),
-                        GSI1_SK: createGSISortKey(latestStorageEntry)
-                    })
-                );
+                entityBatch.put({
+                    ...latestStorageEntry,
+                    ...updatedEntryLevelMetaFields,
+                    PK: partitionKey,
+                    SK: createRevisionSortKey(latestStorageEntry),
+                    TYPE: createType(),
+                    GSI1_PK: createGSIPartitionKey(model, "A"),
+                    GSI1_SK: createGSISortKey(latestStorageEntry)
+                });
 
                 // 2. Update latest record.
-                items.push(
-                    entity.putBatch({
-                        ...latestStorageEntry,
-                        ...updatedEntryLevelMetaFields,
-                        PK: partitionKey,
-                        SK: createLatestSortKey(),
-                        TYPE: createLatestType(),
-                        GSI1_PK: createGSIPartitionKey(model, "L"),
-                        GSI1_SK: createGSISortKey(latestStorageEntry)
-                    })
-                );
+                entityBatch.put({
+                    ...latestStorageEntry,
+                    ...updatedEntryLevelMetaFields,
+                    PK: partitionKey,
+                    SK: createLatestSortKey(),
+                    TYPE: createLatestType(),
+                    GSI1_PK: createGSIPartitionKey(model, "L"),
+                    GSI1_SK: createGSISortKey(latestStorageEntry)
+                });
             }
         }
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
             dataLoaders.clearAll({
                 model
             });
diff --git a/packages/api-i18n-ddb/src/definitions/localeEntity.ts b/packages/api-i18n-ddb/src/definitions/localeEntity.ts
index 4513054a2d7..bdbed1a9747 100644
--- a/packages/api-i18n-ddb/src/definitions/localeEntity.ts
+++ b/packages/api-i18n-ddb/src/definitions/localeEntity.ts
@@ -1,15 +1,19 @@
-import { Entity, Table } from "@webiny/db-dynamodb/toolbox";
-import { I18NContext } from "@webiny/api-i18n/types";
-import { getExtraAttributes } from "@webiny/db-dynamodb/utils/attributes";
+import type { Table } from "@webiny/db-dynamodb/toolbox";
+import type { I18NContext } from "@webiny/api-i18n/types";
+import { getExtraAttributesFromPlugins } from "@webiny/db-dynamodb/utils/attributes";
+import type { IEntity } from "@webiny/db-dynamodb";
+import { createEntity } from "@webiny/db-dynamodb";
 
-export default (params: {
+export interface ILocaleEntityParams {
     context: I18NContext;
     table: Table<string, string, string>;
-}): Entity<any> => {
+}
+
+export default (params: ILocaleEntityParams): IEntity => {
     const { context, table } = params;
     const entityName = "I18NLocale";
-    const attributes = getExtraAttributes(context, entityName);
-    return new Entity({
+    const attributes = getExtraAttributesFromPlugins(context.plugins, entityName);
+    return createEntity({
         name: entityName,
         table,
         attributes: {
diff --git a/packages/api-i18n-ddb/src/definitions/systemEntity.ts b/packages/api-i18n-ddb/src/definitions/systemEntity.ts
index a2e80306a98..44cf24d3800 100644
--- a/packages/api-i18n-ddb/src/definitions/systemEntity.ts
+++ b/packages/api-i18n-ddb/src/definitions/systemEntity.ts
@@ -1,15 +1,17 @@
-import { Entity, Table } from "@webiny/db-dynamodb/toolbox";
-import { I18NContext } from "@webiny/api-i18n/types";
-import { getExtraAttributes } from "@webiny/db-dynamodb/utils/attributes";
+import type { Table } from "@webiny/db-dynamodb/toolbox";
+import type { I18NContext } from "@webiny/api-i18n/types";
+import { getExtraAttributesFromPlugins } from "@webiny/db-dynamodb/utils/attributes";
+import type { IEntity } from "@webiny/db-dynamodb";
+import { createEntity } from "@webiny/db-dynamodb";
 
 export default (params: {
-    context: I18NContext;
+    context: Pick<I18NContext, "plugins">;
     table: Table<string, string, string>;
-}): Entity<any> => {
+}): IEntity => {
     const { context, table } = params;
     const entityName = "I18NSystem";
-    const attributes = getExtraAttributes(context, entityName);
-    return new Entity({
+    const attributes = getExtraAttributesFromPlugins(context.plugins, entityName);
+    return createEntity({
         name: entityName,
         table,
         attributes: {
diff --git a/packages/api-i18n-ddb/src/operations/locales/LocalesStorageOperations.ts b/packages/api-i18n-ddb/src/operations/locales/LocalesStorageOperations.ts
index 96abf44272f..18e344863d6 100644
--- a/packages/api-i18n-ddb/src/operations/locales/LocalesStorageOperations.ts
+++ b/packages/api-i18n-ddb/src/operations/locales/LocalesStorageOperations.ts
@@ -1,4 +1,4 @@
-import {
+import type {
     I18NContext,
     I18NLocaleData,
     I18NLocalesStorageOperations,
@@ -11,17 +11,13 @@ import {
     I18NLocalesStorageOperationsUpdateDefaultParams,
     I18NLocalesStorageOperationsUpdateParams
 } from "@webiny/api-i18n/types";
-import { Entity, Table } from "@webiny/db-dynamodb/toolbox";
+import type { Table } from "@webiny/db-dynamodb/toolbox";
 import WebinyError from "@webiny/error";
 import defineTable from "~/definitions/table";
 import defineLocaleEntity from "~/definitions/localeEntity";
-import { queryAll, QueryAllParams } from "@webiny/db-dynamodb/utils/query";
-import { filterItems } from "@webiny/db-dynamodb/utils/filter";
-import { sortItems } from "@webiny/db-dynamodb/utils/sort";
-import { createListResponse } from "@webiny/db-dynamodb/utils/listResponse";
-import { cleanupItems } from "@webiny/db-dynamodb/utils/cleanup";
+import type { IEntity, IEntityQueryAllParams } from "@webiny/db-dynamodb";
+import { createListResponse, filterItems, sortItems } from "@webiny/db-dynamodb";
 import { LocaleDynamoDbFieldPlugin } from "~/plugins/LocaleDynamoDbFieldPlugin";
-import { deleteItem, getClean, put } from "@webiny/db-dynamodb";
 
 interface ConstructorParams {
     context: I18NContext;
@@ -32,7 +28,7 @@ const DEFAULT_SORT_KEY = "default";
 export class LocalesStorageOperations implements I18NLocalesStorageOperations {
     private readonly context: I18NContext;
     private readonly table: Table<string, string, string>;
-    private readonly entity: Entity<any>;
+    private readonly entity: IEntity;
 
     public constructor({ context }: ConstructorParams) {
         this.context = context;
@@ -48,12 +44,9 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations {
 
     public async getDefault(params: I18NLocalesStorageOperationsGetDefaultParams) {
         try {
-            return await getClean<I18NLocaleData>({
-                entity: this.entity,
-                keys: {
-                    PK: this.createDefaultPartitionKey(params),
-                    SK: DEFAULT_SORT_KEY
-                }
+            return this.entity.getClean<I18NLocaleData>({
+                PK: this.createDefaultPartitionKey(params),
+                SK: DEFAULT_SORT_KEY
             });
         } catch (ex) {
             throw new WebinyError(
@@ -65,12 +58,9 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations {
 
     public async get(params: I18NLocalesStorageOperationsGetParams) {
         try {
-            return await getClean<I18NLocaleData>({
-                entity: this.entity,
-                keys: {
-                    PK: this.createPartitionKey(params),
-                    SK: params.code
-                }
+            return this.entity.getClean<I18NLocaleData>({
+                PK: this.createPartitionKey(params),
+                SK: params.code
             });
         } catch (ex) {
             throw new WebinyError(
@@ -89,12 +79,9 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations {
         };
 
         try {
-            await put({
-                entity: this.entity,
-                item: {
-                    ...locale,
-                    ...keys
-                }
+            await this.entity.put({
+                ...locale,
+                ...keys
             });
             return locale;
         } catch (ex) {
@@ -117,12 +104,9 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations {
             SK: this.getSortKey(locale)
         };
         try {
-            await put({
-                entity: this.entity,
-                item: {
-                    ...locale,
-                    ...keys
-                }
+            await this.entity.put({
+                ...locale,
+                ...keys
             });
             return locale;
         } catch (ex) {
@@ -144,23 +128,24 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations {
         /**
          * Set the locale as the default one.
          */
-        const batch = [
-            {
-                ...locale,
-                PK: this.createPartitionKey(locale),
-                SK: this.getSortKey(locale)
-            },
-            {
-                ...locale,
-                PK: this.createDefaultPartitionKey(locale),
-                SK: DEFAULT_SORT_KEY
-            }
-        ];
+        const entityBatch = this.entity.createEntityWriter();
+
+        entityBatch.put({
+            ...locale,
+            PK: this.createPartitionKey(locale),
+            SK: this.getSortKey(locale)
+        });
+        entityBatch.put({
+            ...locale,
+            PK: this.createDefaultPartitionKey(locale),
+            SK: DEFAULT_SORT_KEY
+        });
+
         /**
          * Set the previous locale not to be default in its data.
          */
         if (previous) {
-            batch.push({
+            entityBatch.put({
                 ...previous,
                 default: false,
                 PK: this.createPartitionKey(locale),
@@ -168,8 +153,10 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations {
             });
         }
 
+        const batch = entityBatch.items;
+
         try {
-            await this.table.batchWrite(batch.map(item => this.entity.putBatch(item)));
+            await entityBatch.execute();
             return locale;
         } catch (ex) {
             throw new WebinyError(
@@ -190,10 +177,7 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations {
             SK: this.getSortKey(locale)
         };
         try {
-            await deleteItem({
-                entity: this.entity,
-                keys
-            });
+            await this.entity.delete(keys);
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Cannot delete I18N locale.",
@@ -220,7 +204,7 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations {
 
         let results: I18NLocaleData[] = [];
         try {
-            results = await queryAll<I18NLocaleData>(queryAllParams);
+            results = await this.entity.queryAll<I18NLocaleData>(queryAllParams);
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Cannot list I18N locales.",
@@ -256,7 +240,7 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations {
          * Use the common db-dynamodb method to create the required response.
          */
         return createListResponse<I18NLocaleData>({
-            items: cleanupItems(this.entity, sortedFiles),
+            items: sortedFiles,
             after,
             totalCount,
             limit
@@ -282,7 +266,7 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations {
 
     private createQueryAllParamsOptions(
         params: I18NLocalesStorageOperationsListParams
-    ): QueryAllParams {
+    ): IEntityQueryAllParams {
         const { where } = params;
 
         const tenant = where.tenant;
@@ -295,7 +279,6 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations {
             delete where.default;
         }
         return {
-            entity: this.entity,
             partitionKey,
             options: {}
         };
diff --git a/packages/api-i18n-ddb/src/operations/system/SystemStorageOperations.ts b/packages/api-i18n-ddb/src/operations/system/SystemStorageOperations.ts
index e333f8eef59..b840cf0c0e3 100644
--- a/packages/api-i18n-ddb/src/operations/system/SystemStorageOperations.ts
+++ b/packages/api-i18n-ddb/src/operations/system/SystemStorageOperations.ts
@@ -1,15 +1,14 @@
-import {
+import type {
     I18NContext,
     I18NSystem,
     I18NSystemStorageOperations,
     I18NSystemStorageOperationsCreate,
     I18NSystemStorageOperationsUpdate
 } from "@webiny/api-i18n/types";
-import { Entity } from "@webiny/db-dynamodb/toolbox";
 import WebinyError from "@webiny/error";
 import defineSystemEntity from "~/definitions/systemEntity";
 import defineTable from "~/definitions/table";
-import { getClean, put } from "@webiny/db-dynamodb";
+import type { IEntity } from "@webiny/db-dynamodb";
 
 interface ConstructorParams {
     context: I18NContext;
@@ -19,7 +18,7 @@ const SORT_KEY = "I18N";
 
 export class SystemStorageOperations implements I18NSystemStorageOperations {
     private readonly _context: I18NContext;
-    private readonly _entity: Entity<any>;
+    private readonly entity: IEntity;
 
     private get partitionKey(): string {
         const tenant = this._context.tenancy.getCurrentTenant();
@@ -35,7 +34,7 @@ export class SystemStorageOperations implements I18NSystemStorageOperations {
             context
         });
 
-        this._entity = defineSystemEntity({
+        this.entity = defineSystemEntity({
             context,
             table
         });
@@ -48,10 +47,7 @@ export class SystemStorageOperations implements I18NSystemStorageOperations {
         };
 
         try {
-            return await getClean<I18NSystem>({
-                entity: this._entity,
-                keys
-            });
+            return await this.entity.getClean<I18NSystem>(keys);
         } catch (ex) {
             throw new WebinyError(
                 "Could not load system data from the database.",
@@ -67,12 +63,9 @@ export class SystemStorageOperations implements I18NSystemStorageOperations {
             SK: SORT_KEY
         };
         try {
-            await put({
-                entity: this._entity,
-                item: {
-                    ...system,
-                    ...keys
-                }
+            await this.entity.put({
+                ...system,
+                ...keys
             });
             return system;
         } catch (ex) {
@@ -90,12 +83,9 @@ export class SystemStorageOperations implements I18NSystemStorageOperations {
             SK: SORT_KEY
         };
         try {
-            await put({
-                entity: this._entity,
-                item: {
-                    ...system,
-                    ...keys
-                }
+            await this.entity.put({
+                ...system,
+                ...keys
             });
             return system;
         } catch (ex) {
diff --git a/packages/api-page-builder-so-ddb-es/src/definitions/pageElasticsearchEntity.ts b/packages/api-page-builder-so-ddb-es/src/definitions/pageElasticsearchEntity.ts
index 0d0bf837a21..5d21fc487f1 100644
--- a/packages/api-page-builder-so-ddb-es/src/definitions/pageElasticsearchEntity.ts
+++ b/packages/api-page-builder-so-ddb-es/src/definitions/pageElasticsearchEntity.ts
@@ -25,6 +25,9 @@ export const createPageElasticsearchEntity = (params: Params): Entity<any> => {
             data: {
                 type: "map"
             },
+            TYPE: {
+                type: "string"
+            },
             ...(attributes || {})
         }
     });
diff --git a/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/dataLoader.ts b/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/dataLoader.ts
index 5a158ff5aad..1fd149702d9 100644
--- a/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/dataLoader.ts
+++ b/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/dataLoader.ts
@@ -1,5 +1,5 @@
 import DataLoader from "dataloader";
-import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead";
+import { batchReadAll } from "@webiny/db-dynamodb";
 import { BlockCategory } from "@webiny/api-page-builder/types";
 import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup";
 import { Entity } from "@webiny/db-dynamodb/toolbox";
diff --git a/packages/api-page-builder-so-ddb-es/src/operations/category/dataLoader.ts b/packages/api-page-builder-so-ddb-es/src/operations/category/dataLoader.ts
index 50a0d6a8358..913ee4fc3b0 100644
--- a/packages/api-page-builder-so-ddb-es/src/operations/category/dataLoader.ts
+++ b/packages/api-page-builder-so-ddb-es/src/operations/category/dataLoader.ts
@@ -1,5 +1,5 @@
 import DataLoader from "dataloader";
-import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead";
+import { batchReadAll } from "@webiny/db-dynamodb";
 import { Category } from "@webiny/api-page-builder/types";
 import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup";
 import { Entity } from "@webiny/db-dynamodb/toolbox";
diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/dataLoader.ts b/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/dataLoader.ts
index c9505ba7858..b3614d766f6 100644
--- a/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/dataLoader.ts
+++ b/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/dataLoader.ts
@@ -1,5 +1,5 @@
 import DataLoader from "dataloader";
-import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead";
+import { batchReadAll } from "@webiny/db-dynamodb";
 import { PageBlock } from "@webiny/api-page-builder/types";
 import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup";
 import { Entity } from "@webiny/db-dynamodb/toolbox";
diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pageTemplate/dataLoader.ts b/packages/api-page-builder-so-ddb-es/src/operations/pageTemplate/dataLoader.ts
index 32437cbacc9..248b6ffe129 100644
--- a/packages/api-page-builder-so-ddb-es/src/operations/pageTemplate/dataLoader.ts
+++ b/packages/api-page-builder-so-ddb-es/src/operations/pageTemplate/dataLoader.ts
@@ -1,5 +1,5 @@
 import DataLoader from "dataloader";
-import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead";
+import { batchReadAll } from "@webiny/db-dynamodb";
 import { PageTemplate } from "@webiny/api-page-builder/types";
 import { Entity } from "@webiny/db-dynamodb/toolbox";
 import { createPrimaryPK } from "./keys";
diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts b/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts
index 2bde41b247c..b523ec37d03 100644
--- a/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts
+++ b/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts
@@ -1,4 +1,4 @@
-import {
+import type {
     Page,
     PageStorageOperations,
     PageStorageOperationsCreateFromParams,
@@ -14,12 +14,12 @@ import {
     PageStorageOperationsUnpublishParams,
     PageStorageOperationsUpdateParams
 } from "@webiny/api-page-builder/types";
-import { Entity } from "@webiny/db-dynamodb/toolbox";
+import type { Entity } from "@webiny/db-dynamodb/toolbox";
 import omit from "lodash/omit";
 import WebinyError from "@webiny/error";
 import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup";
-import { Client } from "@elastic/elasticsearch";
-import {
+import type { Client } from "@elastic/elasticsearch";
+import type {
     ElasticsearchBoolQueryConfig,
     ElasticsearchSearchResponse
 } from "@webiny/api-elasticsearch/types";
@@ -28,11 +28,18 @@ import { createLimit, encodeCursor } from "@webiny/api-elasticsearch";
 import { createElasticsearchQueryBody } from "./elasticsearchQueryBody";
 import { SearchLatestPagesPlugin } from "~/plugins/definitions/SearchLatestPagesPlugin";
 import { SearchPublishedPagesPlugin } from "~/plugins/definitions/SearchPublishedPagesPlugin";
-import { DbItem, queryAll, QueryAllParams, queryOne } from "@webiny/db-dynamodb/utils/query";
+import {
+    createEntityWriteBatch,
+    getClean,
+    put,
+    queryAll,
+    QueryAllParams,
+    queryOne,
+    sortItems
+} from "@webiny/db-dynamodb";
 import { SearchPagesPlugin } from "~/plugins/definitions/SearchPagesPlugin";
-import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite";
 import { getESLatestPageData, getESPublishedPageData } from "./helpers";
-import { PluginsContainer } from "@webiny/plugins";
+import type { PluginsContainer } from "@webiny/plugins";
 import {
     createBasicType,
     createLatestSortKey,
@@ -45,9 +52,7 @@ import {
     createPublishedType,
     createSortKey
 } from "./keys";
-import { sortItems } from "@webiny/db-dynamodb/utils/sort";
 import { PageDynamoDbElasticsearchFieldPlugin } from "~/plugins/definitions/PageDynamoDbElasticsearchFieldPlugin";
-import { getClean, put } from "@webiny/db-dynamodb";
 import { shouldIgnoreEsResponseError } from "~/operations/pages/shouldIgnoreEsResponseError";
 import { logIgnoredEsResponseError } from "~/operations/pages/logIgnoredEsResponseError";
 
@@ -81,24 +86,26 @@ export const createPageStorageOperations = (
             SK: createLatestSortKey()
         };
 
-        const items = [
-            entity.putBatch({
-                ...page,
-                ...versionKeys,
-                TYPE: createBasicType()
-            }),
-            entity.putBatch({
-                ...page,
-                ...latestKeys,
-                TYPE: createLatestType()
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...page,
+                    ...versionKeys,
+                    TYPE: createBasicType()
+                },
+                {
+                    ...page,
+                    ...latestKeys,
+                    TYPE: createLatestType()
+                }
+            ]
+        });
+
         const esData = getESLatestPageData(plugins, page, input);
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items: items
-            });
+            await entityBatch.execute();
+
             await put({
                 entity: esEntity,
                 item: {
@@ -133,26 +140,26 @@ export const createPageStorageOperations = (
             SK: createLatestSortKey()
         };
 
-        const items = [
-            entity.putBatch({
-                ...page,
-                TYPE: createBasicType(),
-                ...versionKeys
-            }),
-            entity.putBatch({
-                ...page,
-                TYPE: createLatestType(),
-                ...latestKeys
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...page,
+                    TYPE: createBasicType(),
+                    ...versionKeys
+                },
+                {
+                    ...page,
+                    TYPE: createLatestType(),
+                    ...latestKeys
+                }
+            ]
+        });
 
         const esData = getESLatestPageData(plugins, page);
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
 
             await put({
                 entity: esEntity,
@@ -195,13 +202,16 @@ export const createPageStorageOperations = (
             keys: latestKeys
         });
 
-        const items = [
-            entity.putBatch({
-                ...page,
-                TYPE: createBasicType(),
-                ...keys
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...page,
+                    TYPE: createBasicType(),
+                    ...keys
+                }
+            ]
+        });
 
         const esData = getESLatestPageData(plugins, page, input);
 
@@ -209,22 +219,17 @@ export const createPageStorageOperations = (
             /**
              * We also update the regular record.
              */
-            items.push(
-                entity.putBatch({
-                    ...page,
-                    TYPE: createLatestType(),
-                    ...latestKeys
-                })
-            );
+            entityBatch.put({
+                ...page,
+                TYPE: createLatestType(),
+                ...latestKeys
+            });
         }
         /**
          * Unfortunately we cannot push regular and es record in the batch write because they are two separate tables.
          */
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
 
             await put({
                 entity: esEntity,
@@ -265,32 +270,35 @@ export const createPageStorageOperations = (
 
         const partitionKey = createPartitionKey(page);
 
-        const items = [
-            entity.deleteBatch({
-                PK: partitionKey,
-                SK: createSortKey(page)
-            })
-        ];
-        const esItems = [];
-        if (publishedPage && publishedPage.id === page.id) {
-            items.push(
-                entity.deleteBatch({
-                    PK: partitionKey,
-                    SK: createPublishedSortKey()
-                })
-            );
-            items.push(
-                entity.deleteBatch({
-                    PK: createPathPartitionKey(page),
-                    SK: createPathSortKey(page)
-                })
-            );
-            esItems.push(
-                esEntity.deleteBatch({
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            delete: [
+                {
                     PK: partitionKey,
-                    SK: createPublishedSortKey()
-                })
-            );
+                    SK: createSortKey(page)
+                }
+            ]
+        });
+
+        const elasticsearchEntityBatch = createEntityWriteBatch({
+            entity: esEntity
+        });
+
+        if (publishedPage && publishedPage.id === page.id) {
+            entityBatch.delete({
+                PK: partitionKey,
+                SK: createPublishedSortKey()
+            });
+
+            entityBatch.delete({
+                PK: createPathPartitionKey(page),
+                SK: createPathSortKey(page)
+            });
+
+            elasticsearchEntityBatch.delete({
+                PK: partitionKey,
+                SK: createPublishedSortKey()
+            });
         }
         let previousLatestPage: Page | null = null;
         if (latestPage && latestPage.id === page.id) {
@@ -303,44 +311,34 @@ export const createPageStorageOperations = (
                 }
             });
             if (previousLatestRecord) {
-                items.push(
-                    entity.putBatch({
-                        ...previousLatestRecord,
-                        TYPE: createLatestType(),
-                        PK: partitionKey,
-                        SK: createLatestSortKey()
-                    })
-                );
-                esItems.push(
-                    esEntity.putBatch({
-                        PK: partitionKey,
-                        SK: createLatestSortKey(),
-                        index: configurations.es(page).index,
-                        data: getESLatestPageData(plugins, previousLatestRecord)
-                    })
-                );
+                entityBatch.put({
+                    ...previousLatestRecord,
+                    TYPE: createLatestType(),
+                    PK: partitionKey,
+                    SK: createLatestSortKey()
+                });
+
+                elasticsearchEntityBatch.put({
+                    PK: partitionKey,
+                    SK: createLatestSortKey(),
+                    index: configurations.es(page).index,
+                    data: getESLatestPageData(plugins, previousLatestRecord)
+                });
+
                 previousLatestPage = cleanupItem(entity, previousLatestRecord);
             }
         }
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not batch write all the page records.",
                 ex.code || "BATCH_WRITE_RECORDS_ERROR"
             );
         }
-        if (esItems.length === 0) {
-            return [page, previousLatestPage];
-        }
+
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items: esItems
-            });
+            await elasticsearchEntityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not batch write all the page Elasticsearch records.",
@@ -370,7 +368,7 @@ export const createPageStorageOperations = (
                 gte: " "
             }
         };
-        let revisions: DbItem<Page>[];
+        let revisions: Awaited<ReturnType<typeof queryAll<Page>>>;
         try {
             revisions = await queryAll<Page>(queryAllParams);
         } catch (ex) {
@@ -387,48 +385,45 @@ export const createPageStorageOperations = (
          * We need to go through all possible entries and delete them.
          * Also, delete the published entry path record.
          */
-        const items = [];
+
+        const entityBatch = createEntityWriteBatch({
+            entity
+        });
+        const elasticsearchEntityBatch = createEntityWriteBatch({
+            entity: esEntity
+        });
+
         let publishedPathEntryDeleted = false;
         for (const revision of revisions) {
             if (revision.status === "published" && !publishedPathEntryDeleted) {
                 publishedPathEntryDeleted = true;
-                items.push(
-                    entity.deleteBatch({
-                        PK: createPathPartitionKey(page),
-                        SK: revision.path
-                    })
-                );
+                entityBatch.delete({
+                    PK: createPathPartitionKey(page),
+                    SK: revision.path
+                });
             }
-            items.push(
-                entity.deleteBatch({
-                    PK: revision.PK,
-                    SK: revision.SK
-                })
-            );
+            entityBatch.delete({
+                PK: revision.PK,
+                SK: revision.SK
+            });
         }
-        const esItems = [
-            esEntity.deleteBatch({
-                PK: partitionKey,
-                SK: createLatestSortKey()
-            })
-        ];
+        elasticsearchEntityBatch.delete({
+            PK: partitionKey,
+            SK: createLatestSortKey()
+        });
+
         /**
          * Delete published record if it is published.
          */
         if (publishedPathEntryDeleted) {
-            esItems.push(
-                esEntity.deleteBatch({
-                    PK: partitionKey,
-                    SK: createPublishedSortKey()
-                })
-            );
+            elasticsearchEntityBatch.delete({
+                PK: partitionKey,
+                SK: createPublishedSortKey()
+            });
         }
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not delete all the page records.",
@@ -436,10 +431,7 @@ export const createPageStorageOperations = (
             );
         }
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items: esItems
-            });
+            await elasticsearchEntityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not delete all the page Elasticsearch records.",
@@ -457,118 +449,100 @@ export const createPageStorageOperations = (
         /**
          * Update the given revision of the page.
          */
-        const items = [
-            entity.putBatch({
-                ...page,
-                TYPE: createBasicType(),
-                PK: createPartitionKey(page),
-                SK: createSortKey(page)
-            })
-        ];
-        const esItems = [];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...page,
+                    TYPE: createBasicType(),
+                    PK: createPartitionKey(page),
+                    SK: createSortKey(page)
+                }
+            ]
+        });
+        const elasticsearchEntityBatch = createEntityWriteBatch({
+            entity: esEntity
+        });
         /**
          * If we are publishing the latest revision, update the latest revision
          * status in ES. We also need to update the latest page revision entry in ES.
          */
         if (latestPage.id === page.id) {
-            items.push(
-                entity.putBatch({
-                    ...page,
-                    TYPE: createLatestType(),
-                    PK: createPartitionKey(page),
-                    SK: createLatestSortKey()
-                })
-            );
+            entityBatch.put({
+                ...page,
+                TYPE: createLatestType(),
+                PK: createPartitionKey(page),
+                SK: createLatestSortKey()
+            });
 
-            esItems.push(
-                esEntity.putBatch({
-                    PK: createPartitionKey(page),
-                    SK: createLatestSortKey(),
-                    index: configurations.es(page).index,
-                    data: getESLatestPageData(plugins, page)
-                })
-            );
+            elasticsearchEntityBatch.put({
+                PK: createPartitionKey(page),
+                SK: createLatestSortKey(),
+                index: configurations.es(page).index,
+                data: getESLatestPageData(plugins, page)
+            });
         }
         /**
          * If we already have a published revision, and it's not the revision being published:
          *  - set the existing published revision to "unpublished"
          */
         if (publishedPage && publishedPage.id !== page.id) {
-            items.push(
-                entity.putBatch({
-                    ...publishedPage,
-                    status: "unpublished",
-                    PK: createPartitionKey(publishedPage),
-                    SK: createSortKey(publishedPage)
-                })
-            );
+            entityBatch.put({
+                ...publishedPage,
+                status: "unpublished",
+                PK: createPartitionKey(publishedPage),
+                SK: createSortKey(publishedPage)
+            });
+
             /**
              * Remove old published path if required.
              */
             if (publishedPage.path !== page.path) {
-                items.push(
-                    entity.deleteBatch({
-                        PK: createPathPartitionKey(page),
-                        SK: publishedPage.path
-                    })
-                );
+                entityBatch.delete({
+                    PK: createPathPartitionKey(page),
+                    SK: publishedPage.path
+                });
             }
         }
 
-        esItems.push(
-            esEntity.putBatch({
-                PK: createPartitionKey(page),
-                SK: createPublishedSortKey(),
-                index: configurations.es(page).index,
-                data: getESPublishedPageData(plugins, page)
-            })
-        );
+        elasticsearchEntityBatch.put({
+            PK: createPartitionKey(page),
+            SK: createPublishedSortKey(),
+            index: configurations.es(page).index,
+            data: getESPublishedPageData(plugins, page)
+        });
 
         /**
          * Update or insert published path.
          */
-        items.push(
-            entity.putBatch({
-                ...page,
-                TYPE: createPublishedPathType(),
-                PK: createPathPartitionKey(page),
-                SK: createPathSortKey(page)
-            })
-        );
+        entityBatch.put({
+            ...page,
+            TYPE: createPublishedPathType(),
+            PK: createPathPartitionKey(page),
+            SK: createPathSortKey(page)
+        });
+
         /**
          * Update or insert published page.
          */
-        items.push(
-            entity.putBatch({
-                ...page,
-                TYPE: createPublishedType(),
-                PK: createPartitionKey(page),
-                SK: createPublishedSortKey()
-            })
-        );
+        entityBatch.put({
+            ...page,
+            TYPE: createPublishedType(),
+            PK: createPartitionKey(page),
+            SK: createPublishedSortKey()
+        });
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not update all the page records when publishing.",
                 ex.code || "UPDATE_RECORDS_ERROR"
             );
         }
-        /**
-         * No point in continuing if there are no items in Elasticsearch data
-         */
-        if (esItems.length === 0) {
-            return page;
-        }
+
         try {
-            await batchWriteAll({
-                table: esEntity.table,
-                items: esItems
-            });
+            await elasticsearchEntityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message ||
@@ -584,74 +558,67 @@ export const createPageStorageOperations = (
 
         page.status = "unpublished";
 
-        const items = [
-            entity.deleteBatch({
-                PK: createPartitionKey(page),
-                SK: createPublishedSortKey()
-            }),
-            entity.deleteBatch({
-                PK: createPathPartitionKey(page),
-                SK: createPathSortKey(page)
-            }),
-            entity.putBatch({
-                ...page,
-                TYPE: createBasicType(),
-                PK: createPartitionKey(page),
-                SK: createSortKey(page)
-            })
-        ];
-        const esItems = [];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            delete: [
+                {
+                    PK: createPartitionKey(page),
+                    SK: createPublishedSortKey()
+                },
+                {
+                    PK: createPathPartitionKey(page),
+                    SK: createPathSortKey(page)
+                }
+            ],
+            put: [
+                {
+                    ...page,
+                    TYPE: createBasicType(),
+                    PK: createPartitionKey(page),
+                    SK: createSortKey(page)
+                }
+            ]
+        });
+
+        const elasticsearchEntityBatch = createEntityWriteBatch({
+            entity: esEntity,
+            delete: [
+                {
+                    PK: createPartitionKey(page),
+                    SK: createPublishedSortKey()
+                }
+            ]
+        });
         /*
          * If we are unpublishing the latest revision, let's also update the latest revision entry's status in ES.
          */
         if (latestPage.id === page.id) {
-            items.push(
-                entity.putBatch({
-                    ...page,
-                    TYPE: createLatestType(),
-                    PK: createPartitionKey(page),
-                    SK: createLatestSortKey()
-                })
-            );
-            esItems.push(
-                esEntity.putBatch({
-                    PK: createPartitionKey(page),
-                    SK: createLatestSortKey(),
-                    index: configurations.es(page).index,
-                    data: getESLatestPageData(plugins, page)
-                })
-            );
-        }
+            entityBatch.put({
+                ...page,
+                TYPE: createLatestType(),
+                PK: createPartitionKey(page),
+                SK: createLatestSortKey()
+            });
 
-        esItems.push(
-            esEntity.deleteBatch({
+            elasticsearchEntityBatch.put({
                 PK: createPartitionKey(page),
-                SK: createPublishedSortKey()
-            })
-        );
+                SK: createLatestSortKey(),
+                index: configurations.es(page).index,
+                data: getESLatestPageData(plugins, page)
+            });
+        }
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not update all the page records when unpublishing.",
                 ex.code || "UPDATE_RECORDS_ERROR"
             );
         }
-        /**
-         * No need to go further if no Elasticsearch items to be applied.
-         */
-        if (esItems.length === 0) {
-            return page;
-        }
+
         try {
-            await batchWriteAll({
-                table: esEntity.table,
-                items: esItems
-            });
+            await elasticsearchEntityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message ||
diff --git a/packages/api-page-builder-so-ddb/package.json b/packages/api-page-builder-so-ddb/package.json
index ce0353d12e0..9ceb55a9e92 100644
--- a/packages/api-page-builder-so-ddb/package.json
+++ b/packages/api-page-builder-so-ddb/package.json
@@ -25,8 +25,7 @@
     "@webiny/error": "0.0.0",
     "@webiny/handler-db": "0.0.0",
     "@webiny/utils": "0.0.0",
-    "dataloader": "^2.0.0",
-    "lodash": "^4.17.21"
+    "dataloader": "^2.0.0"
   },
   "devDependencies": {
     "@babel/cli": "^7.23.9",
diff --git a/packages/api-page-builder-so-ddb/src/operations/blockCategory/dataLoader.ts b/packages/api-page-builder-so-ddb/src/operations/blockCategory/dataLoader.ts
index a8112be7e37..cd030e6a912 100644
--- a/packages/api-page-builder-so-ddb/src/operations/blockCategory/dataLoader.ts
+++ b/packages/api-page-builder-so-ddb/src/operations/blockCategory/dataLoader.ts
@@ -1,5 +1,5 @@
 import DataLoader from "dataloader";
-import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead";
+import { batchReadAll } from "@webiny/db-dynamodb";
 import { BlockCategory } from "@webiny/api-page-builder/types";
 import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup";
 import { Entity } from "@webiny/db-dynamodb/toolbox";
diff --git a/packages/api-page-builder-so-ddb/src/operations/category/dataLoader.ts b/packages/api-page-builder-so-ddb/src/operations/category/dataLoader.ts
index 2c9ebc9c70a..661b32359d6 100644
--- a/packages/api-page-builder-so-ddb/src/operations/category/dataLoader.ts
+++ b/packages/api-page-builder-so-ddb/src/operations/category/dataLoader.ts
@@ -1,5 +1,5 @@
 import DataLoader from "dataloader";
-import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead";
+import { batchReadAll } from "@webiny/db-dynamodb";
 import { Category } from "@webiny/api-page-builder/types";
 import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup";
 import { Entity } from "@webiny/db-dynamodb/toolbox";
diff --git a/packages/api-page-builder-so-ddb/src/operations/pageBlock/dataLoader.ts b/packages/api-page-builder-so-ddb/src/operations/pageBlock/dataLoader.ts
index c9505ba7858..b3614d766f6 100644
--- a/packages/api-page-builder-so-ddb/src/operations/pageBlock/dataLoader.ts
+++ b/packages/api-page-builder-so-ddb/src/operations/pageBlock/dataLoader.ts
@@ -1,5 +1,5 @@
 import DataLoader from "dataloader";
-import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead";
+import { batchReadAll } from "@webiny/db-dynamodb";
 import { PageBlock } from "@webiny/api-page-builder/types";
 import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup";
 import { Entity } from "@webiny/db-dynamodb/toolbox";
diff --git a/packages/api-page-builder-so-ddb/src/operations/pageTemplate/dataLoader.ts b/packages/api-page-builder-so-ddb/src/operations/pageTemplate/dataLoader.ts
index 32437cbacc9..248b6ffe129 100644
--- a/packages/api-page-builder-so-ddb/src/operations/pageTemplate/dataLoader.ts
+++ b/packages/api-page-builder-so-ddb/src/operations/pageTemplate/dataLoader.ts
@@ -1,5 +1,5 @@
 import DataLoader from "dataloader";
-import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead";
+import { batchReadAll } from "@webiny/db-dynamodb";
 import { PageTemplate } from "@webiny/api-page-builder/types";
 import { Entity } from "@webiny/db-dynamodb/toolbox";
 import { createPrimaryPK } from "./keys";
diff --git a/packages/api-page-builder-so-ddb/src/operations/pages/index.ts b/packages/api-page-builder-so-ddb/src/operations/pages/index.ts
index f2619b1fdb7..1938bace62b 100644
--- a/packages/api-page-builder-so-ddb/src/operations/pages/index.ts
+++ b/packages/api-page-builder-so-ddb/src/operations/pages/index.ts
@@ -1,6 +1,5 @@
 import WebinyError from "@webiny/error";
-import lodashGet from "lodash/get";
-import {
+import type {
     Page,
     PageStorageOperations,
     PageStorageOperationsCreateFromParams,
@@ -18,21 +17,21 @@ import {
     PageStorageOperationsUpdateParams
 } from "@webiny/api-page-builder/types";
 import { getClean } from "@webiny/db-dynamodb/utils/get";
-import { Entity } from "@webiny/db-dynamodb/toolbox";
+import type { Entity } from "@webiny/db-dynamodb/toolbox";
 import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup";
+import type { QueryAllParams } from "@webiny/db-dynamodb";
 import {
-    DbItem,
+    createEntityWriteBatch,
+    decodeCursor,
+    encodeCursor,
+    filterItems,
     queryAll,
-    QueryAllParams,
     queryOne,
-    queryOneClean
-} from "@webiny/db-dynamodb/utils/query";
-import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite";
-import { filterItems } from "@webiny/db-dynamodb/utils/filter";
-import { sortItems } from "@webiny/db-dynamodb/utils/sort";
-import { decodeCursor, encodeCursor } from "@webiny/db-dynamodb/utils/cursor";
+    queryOneClean,
+    sortItems
+} from "@webiny/db-dynamodb";
 import { PageDynamoDbFieldPlugin } from "~/plugins/definitions/PageDynamoDbFieldPlugin";
-import { PluginsContainer } from "@webiny/plugins";
+import type { PluginsContainer } from "@webiny/plugins";
 import {
     createLatestPartitionKey,
     createLatestSortKey,
@@ -105,25 +104,26 @@ export const createPageStorageOperations = (
          * - latest
          * - revision
          */
-        const items = [
-            entity.putBatch({
-                ...page,
-                titleLC,
-                ...latestKeys,
-                TYPE: createLatestType()
-            }),
-            entity.putBatch({
-                ...page,
-                titleLC,
-                ...revisionKeys,
-                TYPE: createRevisionType()
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...page,
+                    titleLC,
+                    ...latestKeys,
+                    TYPE: createLatestType()
+                },
+                {
+                    ...page,
+                    titleLC,
+                    ...revisionKeys,
+                    TYPE: createRevisionType()
+                }
+            ]
+        });
+
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
             return page;
         } catch (ex) {
             throw new WebinyError(
@@ -154,24 +154,24 @@ export const createPageStorageOperations = (
          * - latest
          * - revision
          */
-        const items = [
-            entity.putBatch({
-                ...page,
-                ...latestKeys,
-                TYPE: createLatestType()
-            }),
-            entity.putBatch({
-                ...page,
-                ...revisionKeys,
-                TYPE: createRevisionType()
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...page,
+                    ...latestKeys,
+                    TYPE: createLatestType()
+                },
+                {
+                    ...page,
+                    ...revisionKeys,
+                    TYPE: createRevisionType()
+                }
+            ]
+        });
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
             return page;
         } catch (ex) {
             throw new WebinyError(
@@ -211,33 +211,32 @@ export const createPageStorageOperations = (
          * - revision
          * - latest if this is the latest
          */
-        const items = [
-            entity.putBatch({
-                ...page,
-                titleLC,
-                ...revisionKeys,
-                TYPE: createRevisionType()
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...page,
+                    titleLC,
+                    ...revisionKeys,
+                    TYPE: createRevisionType()
+                }
+            ]
+        });
+
         /**
          * Latest if it is the one.
          */
         if (latestPage && latestPage.id === page.id) {
-            items.push(
-                entity.putBatch({
-                    ...page,
-                    titleLC,
-                    ...latestKeys,
-                    TYPE: createLatestType()
-                })
-            );
+            entityBatch.put({
+                ...page,
+                titleLC,
+                ...latestKeys,
+                TYPE: createLatestType()
+            });
         }
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
 
             return page;
         } catch (ex) {
@@ -280,9 +279,13 @@ export const createPageStorageOperations = (
          * We need to update
          * - latest, if it exists, with previous record
          */
-        const items = [entity.deleteBatch(revisionKeys)];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            delete: [revisionKeys]
+        });
+
         if (publishedPage && publishedPage.id === page.id) {
-            items.push(entity.deleteBatch(publishedKeys));
+            entityBatch.delete(publishedKeys);
         }
         let previousLatestPage: Page | null = null;
         if (latestPage && latestPage.id === page.id) {
@@ -296,21 +299,17 @@ export const createPageStorageOperations = (
                 }
             });
             if (previousLatestRecord) {
-                items.push(
-                    entity.putBatch({
-                        ...previousLatestRecord,
-                        ...latestKeys,
-                        TYPE: createLatestType()
-                    })
-                );
+                entityBatch.put({
+                    ...previousLatestRecord,
+                    ...latestKeys,
+                    TYPE: createLatestType()
+                });
+
                 previousLatestPage = cleanupItem(entity, previousLatestRecord);
             }
         }
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not batch write all the page records.",
@@ -347,11 +346,14 @@ export const createPageStorageOperations = (
             SK: createPublishedSortKey(page)
         };
 
-        const items = [entity.deleteBatch(latestKeys)];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            delete: [latestKeys]
+        });
 
-        let revisions: DbItem<Page>[];
+        let revisions: Awaited<ReturnType<typeof queryAll<Page>>> = [];
         try {
-            revisions = await queryAll(queryAllParams);
+            revisions = await queryAll<Page>(queryAllParams);
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not query for all revisions of the page.",
@@ -369,22 +371,17 @@ export const createPageStorageOperations = (
          */
         for (const revision of revisions) {
             if (!deletedPublishedRecord && revision.status === "published") {
-                items.push(entity.deleteBatch(publishedKeys));
+                entityBatch.delete(publishedKeys);
                 deletedPublishedRecord = true;
             }
-            items.push(
-                entity.deleteBatch({
-                    PK: revision.PK,
-                    SK: revision.SK
-                })
-            );
+            entityBatch.delete({
+                PK: revision.PK,
+                SK: revision.SK
+            });
         }
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not delete all the page records.",
@@ -420,22 +417,23 @@ export const createPageStorageOperations = (
         /**
          * Update the given revision of the page.
          */
-        const items = [
-            entity.putBatch({
-                ...page,
-                ...revisionKeys,
-                TYPE: createRevisionType()
-            })
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...page,
+                    ...revisionKeys,
+                    TYPE: createRevisionType()
+                }
+            ]
+        });
 
         if (latestPage.id === page.id) {
-            items.push(
-                entity.putBatch({
-                    ...page,
-                    ...latestKeys,
-                    TYPE: createLatestType()
-                })
-            );
+            entityBatch.put({
+                ...page,
+                ...latestKeys,
+                TYPE: createLatestType()
+            });
         }
         /**
          * If we already have a published revision, and it's not the revision being published:
@@ -446,31 +444,24 @@ export const createPageStorageOperations = (
                 PK: createRevisionPartitionKey(publishedPage),
                 SK: createRevisionSortKey(publishedPage)
             };
-            items.push(
-                entity.putBatch({
-                    ...publishedPage,
-                    status: "unpublished",
-                    ...publishedRevisionKeys,
-                    TYPE: createRevisionType()
-                })
-            );
+            entityBatch.put({
+                ...publishedPage,
+                status: "unpublished",
+                ...publishedRevisionKeys,
+                TYPE: createRevisionType()
+            });
         }
 
-        items.push(
-            entity.putBatch({
-                ...page,
-                ...publishedKeys,
-                GSI1_PK: createPathPartitionKey(page),
-                GSI1_SK: page.path,
-                TYPE: createPublishedType()
-            })
-        );
+        entityBatch.put({
+            ...page,
+            ...publishedKeys,
+            GSI1_PK: createPathPartitionKey(page),
+            GSI1_SK: page.path,
+            TYPE: createPublishedType()
+        });
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not update all the page records when publishing.",
@@ -504,30 +495,28 @@ export const createPageStorageOperations = (
             SK: createPublishedSortKey(page)
         };
 
-        const items = [
-            entity.putBatch({
-                ...page,
-                ...revisionKeys,
-                TYPE: createRevisionType()
-            }),
-            entity.deleteBatch(publishedKeys)
-        ];
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            put: [
+                {
+                    ...page,
+                    ...revisionKeys,
+                    TYPE: createRevisionType()
+                }
+            ],
+            delete: [publishedKeys]
+        });
 
         if (latestPage.id === page.id) {
-            items.push(
-                entity.putBatch({
-                    ...page,
-                    ...latestKeys,
-                    TYPE: createLatestType()
-                })
-            );
+            entityBatch.put({
+                ...page,
+                ...latestKeys,
+                TYPE: createLatestType()
+            });
         }
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not update all the page records when unpublishing.",
@@ -819,7 +808,7 @@ export const createPageStorageOperations = (
             options
         };
 
-        let pages: DbItem<Page>[] = [];
+        let pages: Awaited<ReturnType<typeof queryAll<Page>>> = [];
         try {
             pages = await queryAll<Page>(queryAllParams);
         } catch (ex) {
@@ -833,22 +822,20 @@ export const createPageStorageOperations = (
             );
         }
 
-        const tags = pages.reduce((collection, page) => {
-            let list: string[] = lodashGet(page, "settings.general.tags") as unknown as string[];
-            if (!list || list.length === 0) {
-                return collection;
+        const tags = new Set<string>();
+        for (const page of pages) {
+            let tagList = page.settings?.general?.tags;
+            if (!tagList?.length) {
+                continue;
             } else if (where.search) {
                 const re = new RegExp(where.search, "i");
-                list = list.filter(t => t.match(re) !== null);
+                tagList = tagList.filter(tag => !!tag && tag.match(re) !== null);
             }
-
-            for (const t of list) {
-                collection[t] = undefined;
+            for (const tag of tagList) {
+                tags.add(tag);
             }
-            return collection;
-        }, {} as Record<string, string | undefined>);
-
-        return Object.keys(tags);
+        }
+        return Array.from(tags);
     };
 
     return {
diff --git a/packages/api-prerendering-service-so-ddb/src/operations/queueJob.ts b/packages/api-prerendering-service-so-ddb/src/operations/queueJob.ts
index 3499661f229..f8371e1c7e5 100644
--- a/packages/api-prerendering-service-so-ddb/src/operations/queueJob.ts
+++ b/packages/api-prerendering-service-so-ddb/src/operations/queueJob.ts
@@ -1,14 +1,14 @@
 import WebinyError from "@webiny/error";
-import {
+import type {
     PrerenderingServiceQueueJobStorageOperations,
     PrerenderingServiceStorageOperationsCreateQueueJobParams,
     PrerenderingServiceStorageOperationsDeleteQueueJobsParams,
     QueueJob
 } from "@webiny/api-prerendering-service/types";
-import { Entity } from "@webiny/db-dynamodb/toolbox";
-import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite";
-import { queryAllClean, QueryAllParams } from "@webiny/db-dynamodb/utils/query";
-import { put } from "@webiny/db-dynamodb";
+import type { Entity } from "@webiny/db-dynamodb/toolbox";
+import { createEntityWriteBatch, put } from "@webiny/db-dynamodb";
+import type { QueryAllParams } from "@webiny/db-dynamodb/utils/query";
+import { queryAllClean } from "@webiny/db-dynamodb/utils/query";
 
 export interface CreateQueueJobStorageOperationsParams {
     entity: Entity<any>;
@@ -89,18 +89,18 @@ export const createQueueJobStorageOperations = (
     ) => {
         const { queueJobs } = params;
 
-        const items = queueJobs.map(job => {
-            return entity.deleteBatch({
-                PK: createQueueJobPartitionKey(),
-                SK: createQueueJobSortKey(job.id)
-            });
+        const entityBatch = createEntityWriteBatch({
+            entity,
+            delete: queueJobs.map(job => {
+                return {
+                    PK: createQueueJobPartitionKey(),
+                    SK: createQueueJobSortKey(job.id)
+                };
+            })
         });
 
         try {
-            await batchWriteAll({
-                table: entity.table,
-                items
-            });
+            await entityBatch.execute();
             return queueJobs;
         } catch (ex) {
             throw new WebinyError(
diff --git a/packages/api-prerendering-service-so-ddb/src/operations/render.ts b/packages/api-prerendering-service-so-ddb/src/operations/render.ts
index 07565470dc1..b9fb75a89b9 100644
--- a/packages/api-prerendering-service-so-ddb/src/operations/render.ts
+++ b/packages/api-prerendering-service-so-ddb/src/operations/render.ts
@@ -1,5 +1,5 @@
 import WebinyError from "@webiny/error";
-import {
+import type {
     PrerenderingServiceRenderStorageOperations,
     PrerenderingServiceStorageOperationsCreateRenderParams,
     PrerenderingServiceStorageOperationsCreateTagPathLinksParams,
@@ -12,18 +12,24 @@ import {
     Tag,
     TagPathLink
 } from "@webiny/api-prerendering-service/types";
-import { Entity, EntityQueryOptions } from "@webiny/db-dynamodb/toolbox";
-import { get } from "@webiny/db-dynamodb/utils/get";
-import { queryAll, queryAllClean, QueryAllParams } from "@webiny/db-dynamodb/utils/query";
-import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead";
-import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite";
-import { cleanupItem, cleanupItems } from "@webiny/db-dynamodb/utils/cleanup";
-import { DataContainer } from "~/types";
-import { deleteItem, put } from "@webiny/db-dynamodb";
+import type { Entity, EntityQueryOptions } from "@webiny/db-dynamodb/toolbox";
+import {
+    batchReadAll,
+    cleanupItem,
+    cleanupItems,
+    createEntityWriteBatch,
+    deleteItem,
+    get,
+    put,
+    queryAll,
+    queryAllClean
+} from "@webiny/db-dynamodb";
+import type { QueryAllParams } from "@webiny/db-dynamodb";
+import type { DataContainer } from "~/types";
 
 export interface CreateRenderStorageOperationsParams {
-    entity: Entity<any>;
-    tagPathLinkEntity: Entity<any>;
+    entity: Entity;
+    tagPathLinkEntity: Entity;
 }
 
 export interface CreateTagPathLinkPartitionKeyParams {
@@ -276,29 +282,29 @@ export const createRenderStorageOperations = (
     ) => {
         const { tagPathLinks } = params;
 
-        const items = tagPathLinks.map(item => {
-            return tagPathLinkEntity.putBatch({
-                data: item,
-                TYPE: createTagPathLinkType(),
-                PK: createTagPathLinkPartitionKey({
-                    tenant: item.tenant,
-                    tag: item,
-                    path: item.path
-                }),
-                SK: createTagPathLinkSortKey({
-                    tag: item,
-                    path: item.path
-                }),
-                GSI1_PK: createTagPathLinkGSI1PartitionKey({ tag: item, tenant: item.tenant }),
-                GSI1_SK: createTagPathLinkGSI1SortKey({ tag: item, path: item.path })
-            });
+        const tagPathLinksBatch = createEntityWriteBatch({
+            entity: tagPathLinkEntity,
+            put: tagPathLinks.map(item => {
+                return {
+                    data: item,
+                    TYPE: createTagPathLinkType(),
+                    PK: createTagPathLinkPartitionKey({
+                        tenant: item.tenant,
+                        tag: item,
+                        path: item.path
+                    }),
+                    SK: createTagPathLinkSortKey({
+                        tag: item,
+                        path: item.path
+                    }),
+                    GSI1_PK: createTagPathLinkGSI1PartitionKey({ tag: item, tenant: item.tenant }),
+                    GSI1_SK: createTagPathLinkGSI1SortKey({ tag: item, path: item.path })
+                };
+            })
         });
 
         try {
-            await batchWriteAll({
-                table: tagPathLinkEntity.table,
-                items
-            });
+            await tagPathLinksBatch.execute();
             return tagPathLinks;
         } catch (ex) {
             throw new WebinyError(
@@ -315,25 +321,26 @@ export const createRenderStorageOperations = (
         params: PrerenderingServiceStorageOperationsDeleteTagPathLinksParams
     ): Promise<void> => {
         const { tenant, tags, path } = params;
-        const items = tags.map(tag => {
-            return tagPathLinkEntity.deleteBatch({
-                PK: createTagPathLinkPartitionKey({
-                    tag,
-                    tenant,
-                    path
-                }),
-                SK: createTagPathLinkSortKey({
-                    tag,
-                    path
-                })
-            });
+
+        const tagPathLinksBatch = createEntityWriteBatch({
+            entity: tagPathLinkEntity,
+            delete: tags.map(tag => {
+                return {
+                    PK: createTagPathLinkPartitionKey({
+                        tag,
+                        tenant,
+                        path
+                    }),
+                    SK: createTagPathLinkSortKey({
+                        tag,
+                        path
+                    })
+                };
+            })
         });
 
         try {
-            await batchWriteAll({
-                table: tagPathLinkEntity.table,
-                items
-            });
+            await tagPathLinksBatch.execute();
         } catch (ex) {
             throw new WebinyError(
                 ex.message || "Could not delete tagPathLink records.",
diff --git a/packages/api-security-so-ddb/src/index.ts b/packages/api-security-so-ddb/src/index.ts
index c900f2e9261..628e339a5ce 100644
--- a/packages/api-security-so-ddb/src/index.ts
+++ b/packages/api-security-so-ddb/src/index.ts
@@ -1,5 +1,5 @@
 import { ENTITIES, SecurityStorageParams } from "~/types";
-import {
+import type {
     ApiKey,
     Group,
     ListTenantLinksByTypeParams,
@@ -18,16 +18,19 @@ import {
     createTeamEntity,
     createTenantLinkEntity
 } from "~/definitions/entities";
-import { cleanupItem, cleanupItems } from "@webiny/db-dynamodb/utils/cleanup";
+import type { QueryOneParams } from "@webiny/db-dynamodb";
 import {
+    cleanupItem,
+    cleanupItems,
+    createEntityWriteBatch,
+    deleteItem,
+    getClean,
+    put,
     queryAll,
     queryAllClean,
     queryOneClean,
-    QueryOneParams
-} from "@webiny/db-dynamodb/utils/query";
-import { sortItems } from "@webiny/db-dynamodb/utils/sort";
-import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite";
-import { deleteItem, getClean, put } from "@webiny/db-dynamodb";
+    sortItems
+} from "@webiny/db-dynamodb";
 
 const reservedFields: string[] = ["PK", "SK", "index", "data"];
 
@@ -187,17 +190,20 @@ export const createStorageOperations = (
             }
         },
         async createTenantLinks(links): Promise<void> {
-            const items = links.map(link => {
-                return entities.tenantLinks.putBatch({
-                    PK: `IDENTITY#${link.identity}`,
-                    SK: `LINK#T#${link.tenant}`,
-                    GSI1_PK: `T#${link.tenant}`,
-                    GSI1_SK: `TYPE#${link.type}#IDENTITY#${link.identity}`,
-                    ...cleanupItem(entities.tenantLinks, link)
-                });
+            const batchWrite = createEntityWriteBatch({
+                entity: entities.tenantLinks,
+                put: links.map(link => {
+                    return {
+                        PK: `IDENTITY#${link.identity}`,
+                        SK: `LINK#T#${link.tenant}`,
+                        GSI1_PK: `T#${link.tenant}`,
+                        GSI1_SK: `TYPE#${link.type}#IDENTITY#${link.identity}`,
+                        ...cleanupItem(entities.tenantLinks, link)
+                    };
+                })
             });
 
-            await batchWriteAll({ table, items });
+            await batchWrite.execute();
         },
         async deleteApiKey({ apiKey }) {
             const keys = createApiKeyKeys(apiKey);
@@ -248,14 +254,16 @@ export const createStorageOperations = (
             }
         },
         async deleteTenantLinks(links): Promise<void> {
-            const items = links.map(link => {
-                return entities.tenantLinks.deleteBatch({
-                    PK: `IDENTITY#${link.identity}`,
-                    SK: `LINK#T#${link.tenant}`
-                });
+            const batchWrite = createEntityWriteBatch({
+                entity: entities.tenantLinks,
+                delete: links.map(link => {
+                    return {
+                        PK: `IDENTITY#${link.identity}`,
+                        SK: `LINK#T#${link.tenant}`
+                    };
+                })
             });
-
-            await batchWriteAll({ table, items });
+            await batchWrite.execute();
         },
         async getApiKey({ id, tenant }) {
             const keys = createApiKeyKeys({ id, tenant });
@@ -586,17 +594,20 @@ export const createStorageOperations = (
             }
         },
         async updateTenantLinks(links): Promise<void> {
-            const items = links.map(link => {
-                return entities.tenantLinks.putBatch({
-                    PK: `IDENTITY#${link.identity}`,
-                    SK: `LINK#T#${link.tenant}`,
-                    GSI1_PK: `T#${link.tenant}`,
-                    GSI1_SK: `TYPE#${link.type}#IDENTITY#${link.identity}`,
-                    ...cleanupItem(entities.tenantLinks, link)
-                });
+            const batchWrite = createEntityWriteBatch({
+                entity: entities.tenantLinks,
+                put: links.map(link => {
+                    return {
+                        PK: `IDENTITY#${link.identity}`,
+                        SK: `LINK#T#${link.tenant}`,
+                        GSI1_PK: `T#${link.tenant}`,
+                        GSI1_SK: `TYPE#${link.type}#IDENTITY#${link.identity}`,
+                        ...cleanupItem(entities.tenantLinks, link)
+                    };
+                })
             });
 
-            await batchWriteAll({ table, items });
+            await batchWrite.execute();
         }
     };
 };
diff --git a/packages/api-tenancy-so-ddb/src/index.ts b/packages/api-tenancy-so-ddb/src/index.ts
index 9e8a3cca180..a81ea07bf68 100644
--- a/packages/api-tenancy-so-ddb/src/index.ts
+++ b/packages/api-tenancy-so-ddb/src/index.ts
@@ -1,5 +1,10 @@
-import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead";
-import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite";
+import {
+    batchReadAll,
+    createEntityWriteBatch,
+    createTableWriteBatch,
+    getClean,
+    put
+} from "@webiny/db-dynamodb";
 import { queryAll, QueryAllParams } from "@webiny/db-dynamodb/utils/query";
 import WebinyError from "@webiny/error";
 import { createTable } from "~/definitions/table";
@@ -7,8 +12,7 @@ import { createTenantEntity } from "~/definitions/tenantEntity";
 import { createSystemEntity } from "~/definitions/systemEntity";
 import { createDomainEntity } from "~/definitions/domainEntity";
 import { CreateTenancyStorageOperations, ENTITIES } from "~/types";
-import { ListTenantsParams, System, Tenant, TenantDomain } from "@webiny/api-tenancy/types";
-import { getClean, put } from "@webiny/db-dynamodb";
+import type { ListTenantsParams, System, Tenant, TenantDomain } from "@webiny/api-tenancy/types";
 
 interface TenantDomainRecord {
     PK: string;
@@ -181,20 +185,24 @@ export const createStorageOperations: CreateTenancyStorageOperations = params =>
             };
 
             try {
-                const items: any[] = [
-                    entities.tenants.putBatch({ TYPE: "tenancy.tenant", ...keys, data })
-                ];
-                const newDomains = createNewDomainsRecords(data);
-
-                newDomains.forEach(record => {
-                    items.push(entities.domains.putBatch(record));
+                const tableWrite = createTableWriteBatch({
+                    table: tableInstance
                 });
 
-                await batchWriteAll({
-                    table: tableInstance,
-                    items: items
+                tableWrite.put(entities.tenants, {
+                    TYPE: "tenancy.tenant",
+                    ...keys,
+                    data
                 });
 
+                const newDomains = createNewDomainsRecords(data);
+
+                for (const domain of newDomains) {
+                    tableWrite.put(entities.domains, domain);
+                }
+
+                await tableWrite.execute();
+
                 return data as TTenant;
             } catch (err) {
                 throw WebinyError.from(err, {
@@ -215,8 +223,6 @@ export const createStorageOperations: CreateTenancyStorageOperations = params =>
                 GSI1_SK: `T#${data.parent}#${data.createdOn}`
             };
 
-            const items: any[] = [entities.tenants.putBatch({ ...keys, data })];
-
             const existingDomains = await queryAll<TenantDomain>({
                 entity: entities.domains,
                 partitionKey: "DOMAINS",
@@ -228,31 +234,30 @@ export const createStorageOperations: CreateTenancyStorageOperations = params =>
 
             const newDomains = createNewDomainsRecords(data, existingDomains);
 
+            const tableWrite = createTableWriteBatch({
+                table: tableInstance
+            });
+
+            tableWrite.put(entities.tenants, { ...keys, data });
+
             // Delete domains that are in the DB but are NOT in the settings.
-            const deleteDomains = [];
+
             for (const { fqdn } of existingDomains) {
-                if (!data.settings.domains.find(d => d.fqdn === fqdn)) {
-                    deleteDomains.push({
-                        PK: `DOMAIN#${fqdn}`,
-                        SK: `A`
-                    });
+                if (data.settings.domains.some(d => d.fqdn === fqdn)) {
+                    continue;
                 }
-            }
-
-            try {
-                newDomains.forEach(record => {
-                    items.push(entities.domains.putBatch(record));
-                });
-
-                deleteDomains.forEach(item => {
-                    items.push(entities.domains.deleteBatch(item));
+                tableWrite.delete(entities.domains, {
+                    PK: `DOMAIN#${fqdn}`,
+                    SK: `A`
                 });
+            }
 
-                await batchWriteAll({
-                    table: tableInstance,
-                    items: items
-                });
+            for (const domain of newDomains) {
+                tableWrite.put(entities.domains, domain);
+            }
 
+            try {
+                await tableWrite.execute();
                 return data as TTenant;
             } catch (err) {
                 throw WebinyError.from(err, {
@@ -273,27 +278,25 @@ export const createStorageOperations: CreateTenancyStorageOperations = params =>
                 }
             });
 
-            const items = [
-                entities.tenants.deleteBatch({
-                    PK: `T#${id}`,
-                    SK: "A"
-                })
-            ];
-
-            existingDomains.forEach(domain => {
-                items.push(
-                    entities.domains.deleteBatch({
-                        PK: domain.PK,
-                        SK: domain.SK
-                    })
-                );
+            const batchWrite = createEntityWriteBatch({
+                entity: entities.tenants,
+                delete: [
+                    {
+                        PK: `T#${id}`,
+                        SK: "A"
+                    }
+                ]
             });
 
+            for (const domain of existingDomains) {
+                batchWrite.delete({
+                    PK: domain.PK,
+                    SK: domain.SK
+                });
+            }
+
             // Delete tenant and domain items
-            await batchWriteAll({
-                table: tableInstance,
-                items
-            });
+            await batchWrite.execute();
         }
     };
 };
diff --git a/packages/db-dynamodb/README.md b/packages/db-dynamodb/README.md
index 7990665227b..b6bbb743004 100644
--- a/packages/db-dynamodb/README.md
+++ b/packages/db-dynamodb/README.md
@@ -14,6 +14,86 @@ For more information, please visit
 yarn add @webiny/db-dynamodb
 ```
 
+### Helper classes
+We have some classes that ease the use of dynamodb-toolbox.
+#### [Table](./src/utils/table/Table.ts)
+```typescript
+import { createTable } from "@webiny/db-dynamodb";
+
+const table = createTable({...params});
+const writer = table.createWriter(); // see TableWriteBatch
+const reader = table.createReader(); // see TableReadBatch
+
+const result = await table.scan({...params}); // see scan
+```
+
+#### [Entity](./src/utils/entity/Entity.ts)
+```typescript
+
+import { createEntity } from "@webiny/db-dynamodb";
+
+const entity = createEntity({...params});
+const writer = entity.createWriter(); // see EntityWriteBatch
+const reader = entity.createReader(); // see EntityReadBatch
+const tableWriter = entity.createTableWriter(); // see TableWriteBatch
+
+const getResult = await entity.get({...params}); // see get
+const getCleanResult = await entity.getClean({...params}); // see get
+const queryAllResult = await entity.queryAll({...params}); // see queryAllClean
+const queryOneResult = await entity.queryOne({...params}); // see queryOneClean
+const putResult = await entity.put({...params}); // see put
+
+```
+
+#### [EntityWriteBatch](./src/utils/entity/EntityWriteBatch.ts)
+```typescript
+import { createEntityWriteBatch } from "@webiny/db-dynamodb";
+
+const writer = createEntityWriteBatch({...params});
+
+writer.put({...item});
+writer.delete({...keys});
+writer.delete({...moreKeys});
+
+await writer.execute();
+```
+
+#### [EntityReadBatch](./src/utils/entity/EntityReadBatch.ts)
+```typescript
+import { createEntityReadBatch } from "@webiny/db-dynamodb";
+
+const reader = createEntityReadBatch({...params});
+
+reader.get({...keys});
+reader.get({...moreKeys});
+
+const result = await reader.execute();
+```
+#### [TableWriteBatch](./src/utils/table/TableWriteBatch.ts)
+```typescript
+import { createTableWriteBatch } from "@webiny/db-dynamodb";
+
+const writer = createTableWriteBatch({...params});
+
+writer.put(entity, {...item});
+writer.delete(entity, {...keys});
+writer.delete(entity, {...moreKeys});
+
+await writer.execute();
+```
+#### [TableReadBatch](./src/utils/table/TableReadBatch.ts)
+```typescript
+import {createTableReadBatch} from "@webiny/db-dynamodb";
+
+const reader = createTableReadBatch({...params});
+
+writer.get(entity, {...keys});
+writer.get(entity, {...moreKeys});
+
+const result = await reader.execute();
+```
+
+
 
 ### Helper functions
 We have a number [helper](./src/utils) functions that ease the use of either dynamodb-toolbox, filtering, sorting or just creating proper response.
@@ -64,4 +144,4 @@ This function accepts items (records) to be filtered, a definition of fields to
 #### [sort](./src/utils/sort.ts)
 Sort the DynamoDB records by given sort condition.
 
-This function accepts items (records) to be sorted, sort options (eg. createdBy_ASC, id_DESC, etc.) and a definitions of fields to sort by (not required by default if no field modification is required).
\ No newline at end of file
+This function accepts items (records) to be sorted, sort options (eg. createdBy_ASC, id_DESC, etc.) and a definitions of fields to sort by (not required by default if no field modification is required).
diff --git a/packages/db-dynamodb/package.json b/packages/db-dynamodb/package.json
index 00ab30f0cfb..f61f4d233ca 100644
--- a/packages/db-dynamodb/package.json
+++ b/packages/db-dynamodb/package.json
@@ -14,7 +14,6 @@
     "@webiny/aws-sdk": "0.0.0",
     "@webiny/db": "0.0.0",
     "@webiny/error": "0.0.0",
-    "@webiny/handler-db": "0.0.0",
     "@webiny/plugins": "0.0.0",
     "@webiny/utils": "0.0.0",
     "date-fns": "^2.22.1",
diff --git a/packages/db-dynamodb/src/index.ts b/packages/db-dynamodb/src/index.ts
index 3aa680435aa..e56b1e14553 100644
--- a/packages/db-dynamodb/src/index.ts
+++ b/packages/db-dynamodb/src/index.ts
@@ -1,6 +1,6 @@
 import { default as DynamoDbDriver } from "./DynamoDbDriver";
 
 export * from "./utils";
-export { DbItem } from "./types";
+export type { DbItem } from "./types";
 
 export { DynamoDbDriver };
diff --git a/packages/db-dynamodb/src/toolbox.ts b/packages/db-dynamodb/src/toolbox.ts
index 22b943165eb..7d90fb1c654 100644
--- a/packages/db-dynamodb/src/toolbox.ts
+++ b/packages/db-dynamodb/src/toolbox.ts
@@ -6,6 +6,8 @@ export type {
     TableConstructor
 } from "dynamodb-toolbox/dist/cjs/classes/Table";
 export type {
+    Readonly,
+    EntityConstructor,
     AttributeDefinition,
     EntityQueryOptions,
     AttributeDefinitions
diff --git a/packages/db-dynamodb/src/utils/batchRead.ts b/packages/db-dynamodb/src/utils/batch/batchRead.ts
similarity index 100%
rename from packages/db-dynamodb/src/utils/batchRead.ts
rename to packages/db-dynamodb/src/utils/batch/batchRead.ts
diff --git a/packages/db-dynamodb/src/utils/batchWrite.ts b/packages/db-dynamodb/src/utils/batch/batchWrite.ts
similarity index 79%
rename from packages/db-dynamodb/src/utils/batchWrite.ts
rename to packages/db-dynamodb/src/utils/batch/batchWrite.ts
index b65e5afe94a..6c753bd46dc 100644
--- a/packages/db-dynamodb/src/utils/batchWrite.ts
+++ b/packages/db-dynamodb/src/utils/batch/batchWrite.ts
@@ -1,31 +1,12 @@
 import lodashChunk from "lodash/chunk";
 import { TableDef } from "~/toolbox";
-import { WriteRequest } from "@webiny/aws-sdk/client-dynamodb";
-
-export interface BatchWriteItem {
-    [key: string]: WriteRequest;
-}
+import { BatchWriteItem, BatchWriteResponse, BatchWriteResult } from "./types";
 
 export interface BatchWriteParams {
     table?: TableDef;
     items: BatchWriteItem[];
 }
 
-export interface BatchWriteResponse {
-    next?: () => Promise<BatchWriteResponse>;
-    $metadata: {
-        httpStatusCode: number;
-        requestId: string;
-        attempts: number;
-        totalRetryDelay: number;
-    };
-    UnprocessedItems?: {
-        [table: string]: WriteRequest[];
-    };
-}
-
-export type BatchWriteResult = BatchWriteResponse[];
-
 const hasUnprocessedItems = (result: BatchWriteResponse): boolean => {
     if (typeof result.next !== "function") {
         return false;
diff --git a/packages/db-dynamodb/src/utils/batch/index.ts b/packages/db-dynamodb/src/utils/batch/index.ts
new file mode 100644
index 00000000000..cc976f74632
--- /dev/null
+++ b/packages/db-dynamodb/src/utils/batch/index.ts
@@ -0,0 +1,3 @@
+export * from "./batchRead";
+export * from "./batchWrite";
+export * from "./types";
diff --git a/packages/db-dynamodb/src/utils/batch/types.ts b/packages/db-dynamodb/src/utils/batch/types.ts
new file mode 100644
index 00000000000..f7b3ad9984b
--- /dev/null
+++ b/packages/db-dynamodb/src/utils/batch/types.ts
@@ -0,0 +1,30 @@
+import type { WriteRequest } from "@webiny/aws-sdk/client-dynamodb";
+
+export interface BatchWriteResponse {
+    next?: () => Promise<BatchWriteResponse>;
+    $metadata: {
+        httpStatusCode: number;
+        requestId: string;
+        attempts: number;
+        totalRetryDelay: number;
+    };
+    UnprocessedItems?: {
+        [table: string]: WriteRequest[];
+    };
+}
+
+export type BatchWriteResult = BatchWriteResponse[];
+
+export interface IDeleteBatchItem {
+    PK: string;
+    SK: string;
+}
+
+export type IPutBatchItem<T extends Record<string, any> = Record<string, any>> = {
+    PK: string;
+    SK: string;
+} & T;
+
+export interface BatchWriteItem {
+    [key: string]: WriteRequest;
+}
diff --git a/packages/db-dynamodb/src/utils/delete.ts b/packages/db-dynamodb/src/utils/delete.ts
index 0540e39d57e..b4adc394d08 100644
--- a/packages/db-dynamodb/src/utils/delete.ts
+++ b/packages/db-dynamodb/src/utils/delete.ts
@@ -1,14 +1,14 @@
 import { Entity } from "~/toolbox";
-
-interface Params {
+export interface IDeleteItemKeys {
+    PK: string;
+    SK: string;
+}
+export interface IDeleteItemParams {
     entity: Entity;
-    keys: {
-        PK: string;
-        SK: string;
-    };
+    keys: IDeleteItemKeys;
 }
 
-export const deleteItem = async (params: Params) => {
+export const deleteItem = async (params: IDeleteItemParams) => {
     const { entity, keys } = params;
 
     return await entity.delete(keys, {
diff --git a/packages/db-dynamodb/src/utils/entity/Entity.ts b/packages/db-dynamodb/src/utils/entity/Entity.ts
new file mode 100644
index 00000000000..3e056d2b016
--- /dev/null
+++ b/packages/db-dynamodb/src/utils/entity/Entity.ts
@@ -0,0 +1,112 @@
+import type {
+    AttributeDefinitions,
+    EntityConstructor as BaseEntityConstructor,
+    Readonly,
+    TableDef
+} from "~/toolbox";
+import { Entity as BaseEntity } from "~/toolbox";
+import type { ITableWriteBatch } from "../table/types";
+import type {
+    IEntity,
+    IEntityQueryAllParams,
+    IEntityQueryOneParams,
+    IEntityReadBatch,
+    IEntityWriteBatch
+} from "./types";
+import type { IPutParamsItem } from "../put";
+import { put } from "../put";
+import type { GetRecordParamsKeys } from "../get";
+import { get, getClean } from "../get";
+import type { IDeleteItemKeys } from "../delete";
+import { deleteItem } from "../delete";
+import { createEntityReadBatch } from "./EntityReadBatch";
+import { createEntityWriteBatch } from "./EntityWriteBatch";
+import { createTableWriteBatch } from "~/utils/table/TableWriteBatch";
+import { queryAllClean, queryOneClean } from "../query";
+
+export type EntityConstructor<
+    T extends Readonly<AttributeDefinitions> = Readonly<AttributeDefinitions>
+> = BaseEntityConstructor<
+    TableDef,
+    string,
+    true,
+    true,
+    true,
+    "created",
+    "modified",
+    "entity",
+    false,
+    T
+>;
+
+export class Entity implements IEntity {
+    public readonly entity: BaseEntity;
+
+    public constructor(params: EntityConstructor) {
+        this.entity = new BaseEntity(params);
+    }
+
+    public createEntityReader(): IEntityReadBatch {
+        return createEntityReadBatch({
+            entity: this.entity
+        });
+    }
+
+    public createEntityWriter(): IEntityWriteBatch {
+        return createEntityWriteBatch({
+            entity: this.entity
+        });
+    }
+
+    public createTableWriter(): ITableWriteBatch {
+        return createTableWriteBatch({
+            table: this.entity.table as TableDef
+        });
+    }
+
+    public async put(item: IPutParamsItem): ReturnType<typeof put> {
+        return put({
+            entity: this.entity,
+            item
+        });
+    }
+
+    public async get<T>(keys: GetRecordParamsKeys): ReturnType<typeof get<T>> {
+        return get<T>({
+            entity: this.entity,
+            keys
+        });
+    }
+
+    public async getClean<T>(keys: GetRecordParamsKeys): ReturnType<typeof getClean<T>> {
+        return getClean<T>({
+            entity: this.entity,
+            keys
+        });
+    }
+
+    public async delete(keys: IDeleteItemKeys): ReturnType<typeof deleteItem> {
+        return deleteItem({
+            entity: this.entity,
+            keys
+        });
+    }
+
+    public queryOne<T>(params: IEntityQueryOneParams): Promise<T | null> {
+        return queryOneClean<T>({
+            ...params,
+            entity: this.entity
+        });
+    }
+
+    public queryAll<T>(params: IEntityQueryAllParams): Promise<T[]> {
+        return queryAllClean<T>({
+            ...params,
+            entity: this.entity
+        });
+    }
+}
+
+export const createEntity = (params: EntityConstructor): IEntity => {
+    return new Entity(params);
+};
diff --git a/packages/db-dynamodb/src/utils/entity/EntityReadBatch.ts b/packages/db-dynamodb/src/utils/entity/EntityReadBatch.ts
new file mode 100644
index 00000000000..c1101bf84b5
--- /dev/null
+++ b/packages/db-dynamodb/src/utils/entity/EntityReadBatch.ts
@@ -0,0 +1,55 @@
+import type { IPutBatchItem } from "~/utils/batch/types";
+import type {
+    IEntityReadBatch,
+    IEntityReadBatchBuilder,
+    IEntityReadBatchBuilderGetResponse,
+    IEntityReadBatchKey
+} from "./types";
+import type { TableDef } from "~/toolbox";
+import type { Entity as ToolboxEntity } from "~/toolbox";
+import { batchReadAll } from "~/utils/batch/batchRead";
+import { GenericRecord } from "@webiny/api/types";
+import { createEntityReadBatchBuilder } from "./EntityReadBatchBuilder";
+import type { EntityOption } from "./getEntity";
+import { getEntity } from "./getEntity";
+
+export interface IEntityReadBatchParams {
+    entity: EntityOption;
+    read?: IPutBatchItem[];
+}
+
+export class EntityReadBatch implements IEntityReadBatch {
+    private readonly entity: ToolboxEntity;
+    private readonly builder: IEntityReadBatchBuilder;
+    private readonly _items: IEntityReadBatchBuilderGetResponse[] = [];
+
+    public constructor(params: IEntityReadBatchParams) {
+        this.entity = getEntity(params.entity);
+        this.builder = createEntityReadBatchBuilder(this.entity);
+        for (const item of params.read || []) {
+            this.get(item);
+        }
+    }
+    public get(input: IEntityReadBatchKey | IEntityReadBatchKey[]): void {
+        if (Array.isArray(input)) {
+            this._items.push(
+                ...input.map(item => {
+                    return this.builder.get(item);
+                })
+            );
+            return;
+        }
+        this._items.push(this.builder.get(input));
+    }
+
+    public async execute<T = GenericRecord>() {
+        return await batchReadAll<T>({
+            table: this.entity.table as TableDef,
+            items: this._items
+        });
+    }
+}
+
+export const createEntityReadBatch = (params: IEntityReadBatchParams): IEntityReadBatch => {
+    return new EntityReadBatch(params);
+};
diff --git a/packages/db-dynamodb/src/utils/entity/EntityReadBatchBuilder.ts b/packages/db-dynamodb/src/utils/entity/EntityReadBatchBuilder.ts
new file mode 100644
index 00000000000..9b72ca8ee8c
--- /dev/null
+++ b/packages/db-dynamodb/src/utils/entity/EntityReadBatchBuilder.ts
@@ -0,0 +1,34 @@
+import type { Entity as ToolboxEntity } from "~/toolbox";
+import type {
+    IEntityReadBatchBuilder,
+    IEntityReadBatchBuilderGetResponse,
+    IEntityReadBatchKey
+} from "./types";
+import { WebinyError } from "@webiny/error";
+import { Entity } from "./Entity";
+import type { EntityOption } from "./getEntity";
+import { getEntity } from "./getEntity";
+
+export class EntityReadBatchBuilder implements IEntityReadBatchBuilder {
+    private readonly entity: ToolboxEntity;
+
+    public constructor(entity: EntityOption) {
+        this.entity = getEntity(entity);
+    }
+
+    public get(item: IEntityReadBatchKey): IEntityReadBatchBuilderGetResponse {
+        const result = this.entity.getBatch(item);
+        if (!result.Table) {
+            throw new WebinyError(`No table provided for entity ${this.entity.name}.`);
+        } else if (!result.Key) {
+            throw new WebinyError(`No key provided for entity ${this.entity.name}.`);
+        }
+        return result as IEntityReadBatchBuilderGetResponse;
+    }
+}
+
+export const createEntityReadBatchBuilder = (
+    entity: ToolboxEntity | Entity
+): IEntityReadBatchBuilder => {
+    return new EntityReadBatchBuilder(entity);
+};
diff --git a/packages/db-dynamodb/src/utils/entity/EntityWriteBatch.ts b/packages/db-dynamodb/src/utils/entity/EntityWriteBatch.ts
new file mode 100644
index 00000000000..49eef6beaa3
--- /dev/null
+++ b/packages/db-dynamodb/src/utils/entity/EntityWriteBatch.ts
@@ -0,0 +1,77 @@
+import type { TableDef } from "~/toolbox";
+import type { Entity as ToolboxEntity } from "~/toolbox";
+import { batchWriteAll } from "~/utils/batch/batchWrite";
+import type {
+    BatchWriteItem,
+    BatchWriteResult,
+    IDeleteBatchItem,
+    IPutBatchItem
+} from "~/utils/batch/types";
+import type { IEntityWriteBatch, IEntityWriteBatchBuilder } from "./types";
+import type { ITableWriteBatch } from "~/utils/table/types";
+import { createTableWriteBatch } from "~/utils/table/TableWriteBatch";
+import { createEntityWriteBatchBuilder } from "./EntityWriteBatchBuilder";
+import type { EntityOption } from "./getEntity";
+import { getEntity } from "./getEntity";
+
+export interface IEntityWriteBatchParams {
+    entity: EntityOption;
+    put?: IPutBatchItem[];
+    delete?: IDeleteBatchItem[];
+}
+
+export class EntityWriteBatch implements IEntityWriteBatch {
+    private readonly entity: ToolboxEntity;
+    private readonly _items: BatchWriteItem[] = [];
+    private readonly builder: IEntityWriteBatchBuilder;
+
+    public get total(): number {
+        return this._items.length;
+    }
+
+    public get items(): BatchWriteItem[] {
+        return Array.from(this._items);
+    }
+
+    public constructor(params: IEntityWriteBatchParams) {
+        this.entity = getEntity(params.entity);
+        this.builder = createEntityWriteBatchBuilder(this.entity);
+        for (const item of params.put || []) {
+            this.put(item);
+        }
+        for (const item of params.delete || []) {
+            this.delete(item);
+        }
+    }
+
+    public put<T extends Record<string, any>>(item: IPutBatchItem<T>): void {
+        this._items.push(this.builder.put(item));
+    }
+
+    public delete(item: IDeleteBatchItem): void {
+        this._items.push(this.builder.delete(item));
+    }
+
+    public combine(items: BatchWriteItem[]): ITableWriteBatch {
+        return createTableWriteBatch({
+            table: this.entity!.table as TableDef,
+            items: this._items.concat(items)
+        });
+    }
+
+    public async execute(): Promise<BatchWriteResult> {
+        if (this._items.length === 0) {
+            return [];
+        }
+        const items = Array.from(this._items);
+        this._items.length = 0;
+        return await batchWriteAll({
+            items,
+            table: this.entity.table
+        });
+    }
+}
+
+export const createEntityWriteBatch = (params: IEntityWriteBatchParams): IEntityWriteBatch => {
+    return new EntityWriteBatch(params);
+};
diff --git a/packages/db-dynamodb/src/utils/entity/EntityWriteBatchBuilder.ts b/packages/db-dynamodb/src/utils/entity/EntityWriteBatchBuilder.ts
new file mode 100644
index 00000000000..73cb5cff13e
--- /dev/null
+++ b/packages/db-dynamodb/src/utils/entity/EntityWriteBatchBuilder.ts
@@ -0,0 +1,28 @@
+import type { Entity } from "~/toolbox";
+import type { BatchWriteItem, IDeleteBatchItem, IPutBatchItem } from "~/utils/batch/types";
+import type { IEntityWriteBatchBuilder } from "./types";
+import type { EntityOption } from "./getEntity";
+import { getEntity } from "./getEntity";
+
+export class EntityWriteBatchBuilder implements IEntityWriteBatchBuilder {
+    private readonly entity: Entity;
+
+    public constructor(entity: EntityOption) {
+        this.entity = getEntity(entity);
+    }
+
+    public put<T extends Record<string, any>>(item: IPutBatchItem<T>): BatchWriteItem {
+        return this.entity.putBatch(item, {
+            execute: true,
+            strictSchemaCheck: false
+        });
+    }
+
+    public delete(item: IDeleteBatchItem): BatchWriteItem {
+        return this.entity.deleteBatch(item);
+    }
+}
+
+export const createEntityWriteBatchBuilder = (entity: Entity): IEntityWriteBatchBuilder => {
+    return new EntityWriteBatchBuilder(entity);
+};
diff --git a/packages/db-dynamodb/src/utils/entity/getEntity.ts b/packages/db-dynamodb/src/utils/entity/getEntity.ts
new file mode 100644
index 00000000000..5634f6d3b7d
--- /dev/null
+++ b/packages/db-dynamodb/src/utils/entity/getEntity.ts
@@ -0,0 +1,14 @@
+import { Entity as ToolboxEntity } from "~/toolbox";
+import { Entity } from "./Entity";
+
+export type EntityOption = ToolboxEntity | Entity;
+
+export const getEntity = (entity: EntityOption): ToolboxEntity => {
+    const result = entity instanceof ToolboxEntity ? entity : entity.entity;
+    if (!result.name) {
+        throw new Error(`No name provided for entity.`);
+    } else if (!result.table) {
+        throw new Error(`No table provided for entity ${result.name}.`);
+    }
+    return result;
+};
diff --git a/packages/db-dynamodb/src/utils/entity/index.ts b/packages/db-dynamodb/src/utils/entity/index.ts
new file mode 100644
index 00000000000..cab547e858b
--- /dev/null
+++ b/packages/db-dynamodb/src/utils/entity/index.ts
@@ -0,0 +1,7 @@
+export * from "./Entity";
+export * from "./EntityReadBatch";
+export * from "./EntityReadBatchBuilder";
+export * from "./EntityWriteBatch";
+export * from "./EntityWriteBatchBuilder";
+export * from "./getEntity";
+export * from "./types";
diff --git a/packages/db-dynamodb/src/utils/entity/types.ts b/packages/db-dynamodb/src/utils/entity/types.ts
new file mode 100644
index 00000000000..a3322d22d4e
--- /dev/null
+++ b/packages/db-dynamodb/src/utils/entity/types.ts
@@ -0,0 +1,69 @@
+import type { Entity as BaseEntity } from "dynamodb-toolbox";
+import type {
+    BatchWriteItem,
+    BatchWriteResult,
+    IDeleteBatchItem,
+    IPutBatchItem
+} from "~/utils/batch/types";
+import type { GenericRecord } from "@webiny/api/types";
+import type { TableDef } from "~/toolbox";
+import type { ITableWriteBatch } from "~/utils/table/types";
+import type { IPutParamsItem, put } from "~/utils/put";
+import type { QueryAllParams, QueryOneParams } from "~/utils/query";
+import type { get, getClean, GetRecordParamsKeys } from "~/utils/get";
+import type { deleteItem, IDeleteItemKeys } from "~/utils/delete";
+import type { batchReadAll } from "~/utils/batch/batchRead";
+
+export type IEntityQueryOneParams = Omit<QueryOneParams, "entity">;
+
+export type IEntityQueryAllParams = Omit<QueryAllParams, "entity">;
+
+export interface IEntity {
+    readonly entity: BaseEntity;
+    createEntityReader(): IEntityReadBatch;
+    createEntityWriter(): IEntityWriteBatch;
+    createTableWriter(): ITableWriteBatch;
+    put(item: IPutParamsItem): ReturnType<typeof put>;
+    get<T>(keys: GetRecordParamsKeys): ReturnType<typeof get<T>>;
+    getClean<T>(keys: GetRecordParamsKeys): ReturnType<typeof getClean<T>>;
+    delete(keys: IDeleteItemKeys): ReturnType<typeof deleteItem>;
+    queryOne<T>(params: IEntityQueryOneParams): Promise<T | null>;
+    queryAll<T>(params: IEntityQueryAllParams): Promise<T[]>;
+}
+
+export interface IEntityWriteBatchBuilder {
+    // readonly entity: Entity;
+    put<T extends Record<string, any>>(item: IPutBatchItem<T>): BatchWriteItem;
+    delete(item: IDeleteBatchItem): BatchWriteItem;
+}
+
+export interface IEntityWriteBatch {
+    readonly total: number;
+    // readonly entity: Entity;
+    readonly items: BatchWriteItem[];
+    // readonly builder: IEntityWriteBatchBuilder;
+
+    put(item: IPutBatchItem): void;
+    delete(item: IDeleteBatchItem): void;
+    execute(): Promise<BatchWriteResult>;
+    combine(items: BatchWriteItem[]): ITableWriteBatch;
+}
+
+export interface IEntityReadBatchKey {
+    PK: string;
+    SK: string;
+}
+
+export interface IEntityReadBatch {
+    get(input: IEntityReadBatchKey | IEntityReadBatchKey[]): void;
+    execute<T = GenericRecord>(): ReturnType<typeof batchReadAll<T>>;
+}
+
+export interface IEntityReadBatchBuilderGetResponse {
+    Table: TableDef;
+    Key: IEntityReadBatchKey;
+}
+
+export interface IEntityReadBatchBuilder {
+    get(item: IEntityReadBatchKey): IEntityReadBatchBuilderGetResponse;
+}
diff --git a/packages/db-dynamodb/src/utils/get.ts b/packages/db-dynamodb/src/utils/get.ts
index b3e9d0f52e0..b3d29980851 100644
--- a/packages/db-dynamodb/src/utils/get.ts
+++ b/packages/db-dynamodb/src/utils/get.ts
@@ -1,12 +1,14 @@
 import { Entity } from "~/toolbox";
 import { cleanupItem } from "~/utils/cleanup";
 
+export interface GetRecordParamsKeys {
+    PK: string;
+    SK: string;
+}
+
 export interface GetRecordParams {
     entity: Entity;
-    keys: {
-        PK: string;
-        SK: string;
-    };
+    keys: GetRecordParamsKeys;
 }
 
 /**
diff --git a/packages/db-dynamodb/src/utils/index.ts b/packages/db-dynamodb/src/utils/index.ts
index b3823773402..6e5246f3896 100644
--- a/packages/db-dynamodb/src/utils/index.ts
+++ b/packages/db-dynamodb/src/utils/index.ts
@@ -1,5 +1,3 @@
-export * from "./batchRead";
-export * from "./batchWrite";
 export * from "./cleanup";
 export * from "./createEntity";
 export * from "./createTable";
@@ -13,5 +11,7 @@ export * from "./query";
 export * from "./count";
 export * from "./scan";
 export * from "./sort";
-export * from "./table";
 export * from "./update";
+export * from "./batch";
+export * from "./entity";
+export * from "./table";
diff --git a/packages/db-dynamodb/src/utils/put.ts b/packages/db-dynamodb/src/utils/put.ts
index 65f1a193552..f426445103c 100644
--- a/packages/db-dynamodb/src/utils/put.ts
+++ b/packages/db-dynamodb/src/utils/put.ts
@@ -1,18 +1,21 @@
 import { Entity } from "~/toolbox";
 
-interface Params {
+export interface IPutParamsItem {
+    PK: string;
+    SK: string;
+    [key: string]: any;
+}
+
+export interface IPutParams {
     entity: Entity;
-    item: {
-        PK: string;
-        SK: string;
-        [key: string]: any;
-    };
+    item: IPutParamsItem;
 }
 
-export const put = async (params: Params) => {
+export const put = async (params: IPutParams) => {
     const { entity, item } = params;
 
     return await entity.put(item, {
-        execute: true
+        execute: true,
+        strictSchemaCheck: false
     });
 };
diff --git a/packages/db-dynamodb/src/utils/scan.ts b/packages/db-dynamodb/src/utils/scan.ts
index 6feac2268f1..8606f85a41a 100644
--- a/packages/db-dynamodb/src/utils/scan.ts
+++ b/packages/db-dynamodb/src/utils/scan.ts
@@ -1,8 +1,8 @@
-import { ScanInput, ScanOutput } from "@webiny/aws-sdk/client-dynamodb";
-import { Entity, ScanOptions, Table } from "~/toolbox";
+import type { ScanInput, ScanOutput } from "@webiny/aws-sdk/client-dynamodb";
+import type { Entity, ScanOptions, TableDef } from "~/toolbox";
 import { executeWithRetry, ExecuteWithRetryOptions } from "@webiny/utils";
 
-export type { ScanOptions };
+export type { ScanOptions, ScanInput, ScanOutput };
 
 export interface BaseScanParams {
     options?: ScanOptions;
@@ -10,7 +10,7 @@ export interface BaseScanParams {
 }
 
 export interface ScanWithTable extends BaseScanParams {
-    table: Table<any, any, any>;
+    table: TableDef;
     entity?: never;
 }
 
diff --git a/packages/db-dynamodb/src/utils/table.ts b/packages/db-dynamodb/src/utils/table.ts
deleted file mode 100644
index a435ba992d0..00000000000
--- a/packages/db-dynamodb/src/utils/table.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import WebinyError from "@webiny/error";
-import { DbContext } from "@webiny/handler-db/types";
-
-/**
- * Will be removed in favor of passing the table name directly to the storage operations.
- *
- * @deprecated
- */
-export const getTable = <T extends DbContext>(context: T): string => {
-    if (!context.db) {
-        throw new WebinyError("Missing db on context.", "DB_ERROR");
-    } else if (!context.db.table) {
-        throw new WebinyError("Missing table on context.db.", "TABLE_ERROR");
-    }
-    return context.db.table;
-};
diff --git a/packages/db-dynamodb/src/utils/table/Table.ts b/packages/db-dynamodb/src/utils/table/Table.ts
new file mode 100644
index 00000000000..7d7fba286ed
--- /dev/null
+++ b/packages/db-dynamodb/src/utils/table/Table.ts
@@ -0,0 +1,44 @@
+import type { TableConstructor } from "~/toolbox";
+import { Table as BaseTable } from "~/toolbox";
+import type {
+    ITable,
+    ITableReadBatch,
+    ITableScanParams,
+    ITableScanResponse,
+    ITableWriteBatch
+} from "./types";
+import { createTableWriteBatch } from "./TableWriteBatch";
+import { createTableReadBatch } from "./TableReadBatch";
+import { scan } from "../scan";
+
+export class Table<
+    Name extends string = string,
+    PartitionKey extends string = string,
+    SortKey extends string = string
+> implements ITable
+{
+    public readonly table: BaseTable<Name, PartitionKey, SortKey>;
+
+    public constructor(params: TableConstructor<Name, PartitionKey, SortKey>) {
+        this.table = new BaseTable(params);
+    }
+
+    public createWriter(): ITableWriteBatch {
+        return createTableWriteBatch({
+            table: this.table
+        });
+    }
+
+    public createReader(): ITableReadBatch {
+        return createTableReadBatch({
+            table: this.table
+        });
+    }
+
+    public async scan<T>(params: ITableScanParams): Promise<ITableScanResponse<T>> {
+        return scan<T>({
+            ...params,
+            table: this.table
+        });
+    }
+}
diff --git a/packages/db-dynamodb/src/utils/table/TableReadBatch.ts b/packages/db-dynamodb/src/utils/table/TableReadBatch.ts
new file mode 100644
index 00000000000..826bae5f96d
--- /dev/null
+++ b/packages/db-dynamodb/src/utils/table/TableReadBatch.ts
@@ -0,0 +1,75 @@
+import type { Entity, TableDef } from "~/toolbox";
+import type {
+    IEntityReadBatchBuilder,
+    IEntityReadBatchBuilderGetResponse
+} from "~/utils/entity/types";
+import { batchReadAll } from "~/utils/batch/batchRead";
+import { createEntityReadBatchBuilder } from "~/utils/entity/EntityReadBatchBuilder";
+import type { GenericRecord } from "@webiny/api/types";
+import { WebinyError } from "@webiny/error";
+import type { ITableReadBatch, ITableReadBatchKey } from "./types";
+
+export interface ITableReadBatchParams {
+    table: TableDef;
+}
+
+export class TableReadBatch implements ITableReadBatch {
+    private readonly table: TableDef;
+
+    private readonly _items: IEntityReadBatchBuilderGetResponse[] = [];
+    private readonly builders: Map<string, IEntityReadBatchBuilder> = new Map();
+
+    public constructor(params: ITableReadBatchParams) {
+        this.table = params.table;
+    }
+
+    public get total(): number {
+        return this._items.length;
+    }
+
+    public get items(): IEntityReadBatchBuilderGetResponse[] {
+        return Array.from(this._items);
+    }
+
+    public get(entity: Entity, input: ITableReadBatchKey): void {
+        const builder = this.getBuilder(entity);
+
+        const items = Array.isArray(input) ? input : [input];
+        for (const item of items) {
+            /**
+             * We cannot read from two tables at the same time, so check for that.
+             */
+            if (this.table.name !== entity.table!.name) {
+                throw new WebinyError(`Cannot read from two different tables at the same time.`);
+            }
+
+            this._items.push(builder.get(item));
+        }
+    }
+
+    public async execute<T = GenericRecord>(): Promise<T[]> {
+        if (this._items.length === 0) {
+            return [];
+        }
+        const items = Array.from(this._items);
+        this._items.length = 0;
+        return await batchReadAll<T>({
+            items,
+            table: this.table
+        });
+    }
+
+    private getBuilder(entity: Entity): IEntityReadBatchBuilder {
+        const builder = this.builders.get(entity.name);
+        if (builder) {
+            return builder;
+        }
+        const newBuilder = createEntityReadBatchBuilder(entity);
+        this.builders.set(entity.name, newBuilder);
+        return newBuilder;
+    }
+}
+
+export const createTableReadBatch = (params: ITableReadBatchParams): ITableReadBatch => {
+    return new TableReadBatch(params);
+};
diff --git a/packages/db-dynamodb/src/utils/table/TableWriteBatch.ts b/packages/db-dynamodb/src/utils/table/TableWriteBatch.ts
new file mode 100644
index 00000000000..a8cbf0c5c9b
--- /dev/null
+++ b/packages/db-dynamodb/src/utils/table/TableWriteBatch.ts
@@ -0,0 +1,84 @@
+import type { Entity, TableDef } from "~/toolbox";
+import type {
+    BatchWriteItem,
+    BatchWriteResult,
+    IDeleteBatchItem,
+    IPutBatchItem
+} from "~/utils/batch/types";
+import type { IEntityWriteBatchBuilder } from "~/utils/entity/types";
+import { batchWriteAll } from "~/utils/batch/batchWrite";
+import { createEntityWriteBatchBuilder } from "~/utils/entity/EntityWriteBatchBuilder";
+import type { ITableWriteBatch } from "./types";
+
+export interface ITableWriteBatchParams {
+    table: TableDef;
+    items?: BatchWriteItem[];
+}
+
+export class TableWriteBatch implements ITableWriteBatch {
+    private readonly table: TableDef;
+    private readonly _items: BatchWriteItem[] = [];
+    private readonly builders: Map<string, IEntityWriteBatchBuilder> = new Map();
+
+    public get total(): number {
+        return this._items.length;
+    }
+
+    public get items(): BatchWriteItem[] {
+        return Array.from(this._items);
+    }
+
+    public constructor(params: ITableWriteBatchParams) {
+        this.table = params.table;
+        if (!params.items?.length) {
+            return;
+        }
+        this._items.push(...params.items);
+    }
+
+    public put(entity: Entity, item: IPutBatchItem): void {
+        const builder = this.getBuilder(entity);
+        this._items.push(builder.put(item));
+    }
+
+    public delete(entity: Entity, item: IDeleteBatchItem): void {
+        const builder = this.getBuilder(entity);
+        this._items.push(builder.delete(item));
+    }
+
+    public combine(items: BatchWriteItem[]): ITableWriteBatch {
+        return createTableWriteBatch({
+            table: this.table,
+            items: this._items.concat(items)
+        });
+    }
+
+    public async execute(): Promise<BatchWriteResult> {
+        if (this._items.length === 0) {
+            return [];
+        }
+        const items = Array.from(this._items);
+        this._items.length = 0;
+        return await batchWriteAll({
+            items,
+            table: this.table
+        });
+    }
+
+    private getBuilder(entity: Entity): IEntityWriteBatchBuilder {
+        if (!entity.name) {
+            throw new Error("Entity must have a name.");
+        }
+        const builder = this.builders.get(entity.name);
+        if (builder) {
+            return builder;
+        }
+        const newBuilder = createEntityWriteBatchBuilder(entity);
+        this.builders.set(entity.name, newBuilder);
+        return newBuilder;
+    }
+}
+
+export const createTableWriteBatch = (params: ITableWriteBatchParams): ITableWriteBatch => {
+    return new TableWriteBatch(params);
+};
diff --git a/packages/db-dynamodb/src/utils/table/index.ts b/packages/db-dynamodb/src/utils/table/index.ts
new file mode 100644
index 00000000000..fd8e934067a
--- /dev/null
+++ b/packages/db-dynamodb/src/utils/table/index.ts
@@ -0,0 +1,4 @@
+export * from "./Table";
+export * from "./TableReadBatch";
+export * from "./TableWriteBatch";
+export * from "./types";
diff --git a/packages/db-dynamodb/src/utils/table/types.ts b/packages/db-dynamodb/src/utils/table/types.ts
new file mode 100644
index 00000000000..c35ce53f1e6
--- /dev/null
+++ b/packages/db-dynamodb/src/utils/table/types.ts
@@ -0,0 +1,53 @@
+import type { TableDef } from "dynamodb-toolbox/dist/cjs/classes/Table/types";
+import type {
+    BatchWriteItem,
+    BatchWriteResult,
+    IDeleteBatchItem,
+    IPutBatchItem
+} from "~/utils/batch/types";
+import type { BaseScanParams, ScanResponse } from "../scan";
+import type { Entity } from "~/toolbox";
+import type { GenericRecord } from "@webiny/api/types";
+
+export type ITableScanParams = BaseScanParams;
+
+export type ITableScanResponse<T> = ScanResponse<T>;
+
+export interface ITable {
+    table: TableDef;
+    createWriter(): ITableWriteBatch;
+    createReader(): ITableReadBatch;
+    scan<T>(params: ITableScanParams): Promise<ITableScanResponse<T>>;
+}
+
+export interface ITableWriteBatch {
+    readonly total: number;
+    // readonly table: TableDef;
+    readonly items: BatchWriteItem[];
+    put(entity: Entity, item: IPutBatchItem): void;
+    delete(entity: Entity, item: IDeleteBatchItem): void;
+    execute(): Promise<BatchWriteResult>;
+    combine(items: BatchWriteItem[]): ITableWriteBatch;
+}
+
+export interface ITableReadBatchKey {
+    PK: string;
+    SK: string;
+}
+
+export interface ITableReadBatchBuilderGetResponse {
+    Table: TableDef;
+    Key: ITableReadBatchKey;
+}
+
+export interface ITableReadBatchKey {
+    PK: string;
+    SK: string;
+}
+
+export interface ITableReadBatch {
+    readonly total: number;
+    readonly items: ITableReadBatchBuilderGetResponse[];
+    get(entity: Entity, input: ITableReadBatchKey | ITableReadBatchKey[]): void;
+    execute<T = GenericRecord>(): Promise<T[]>;
+}
diff --git a/packages/db-dynamodb/src/utils/update.ts b/packages/db-dynamodb/src/utils/update.ts
index b7fad30da83..c4e563972ab 100644
--- a/packages/db-dynamodb/src/utils/update.ts
+++ b/packages/db-dynamodb/src/utils/update.ts
@@ -5,6 +5,7 @@ interface Params {
     item: {
         PK: string;
         SK: string;
+        TYPE?: string;
         [key: string]: any;
     };
 }
@@ -13,6 +14,7 @@ export const update = async (params: Params) => {
     const { entity, item } = params;
 
     return await entity.update(item, {
-        execute: true
+        execute: true,
+        strictSchemaCheck: false
     });
 };
diff --git a/packages/db-dynamodb/tsconfig.build.json b/packages/db-dynamodb/tsconfig.build.json
index d14ec0b973f..a1e7b5b5b27 100644
--- a/packages/db-dynamodb/tsconfig.build.json
+++ b/packages/db-dynamodb/tsconfig.build.json
@@ -6,7 +6,6 @@
     { "path": "../aws-sdk/tsconfig.build.json" },
     { "path": "../db/tsconfig.build.json" },
     { "path": "../error/tsconfig.build.json" },
-    { "path": "../handler-db/tsconfig.build.json" },
     { "path": "../plugins/tsconfig.build.json" },
     { "path": "../utils/tsconfig.build.json" }
   ],
diff --git a/packages/db-dynamodb/tsconfig.json b/packages/db-dynamodb/tsconfig.json
index 13fd84d1241..df4af1353b1 100644
--- a/packages/db-dynamodb/tsconfig.json
+++ b/packages/db-dynamodb/tsconfig.json
@@ -6,7 +6,6 @@
     { "path": "../aws-sdk" },
     { "path": "../db" },
     { "path": "../error" },
-    { "path": "../handler-db" },
     { "path": "../plugins" },
     { "path": "../utils" }
   ],
@@ -25,8 +24,6 @@
       "@webiny/db": ["../db/src"],
       "@webiny/error/*": ["../error/src/*"],
       "@webiny/error": ["../error/src"],
-      "@webiny/handler-db/*": ["../handler-db/src/*"],
-      "@webiny/handler-db": ["../handler-db/src"],
       "@webiny/plugins/*": ["../plugins/src/*"],
       "@webiny/plugins": ["../plugins/src"],
       "@webiny/utils/*": ["../utils/src/*"],
diff --git a/packages/migrations/src/migrations/5.35.0/001/ddb-es/FileDataMigration.ts b/packages/migrations/src/migrations/5.35.0/001/ddb-es/FileDataMigration.ts
index 0f2750f9257..b4848b89c70 100644
--- a/packages/migrations/src/migrations/5.35.0/001/ddb-es/FileDataMigration.ts
+++ b/packages/migrations/src/migrations/5.35.0/001/ddb-es/FileDataMigration.ts
@@ -5,12 +5,12 @@ import { PrimitiveValue } from "@webiny/api-elasticsearch/types";
 import { executeWithRetry } from "@webiny/utils";
 import { DataMigration, DataMigrationContext } from "@webiny/data-migration";
 import {
-    createStandardEntity,
-    queryOne,
-    queryAll,
     batchWriteAll,
+    createStandardEntity,
+    esGetIndexName,
     esQueryAllWithCallback,
-    esGetIndexName
+    queryAll,
+    queryOne
 } from "~/utils";
 import { createFileEntity, getFileData, legacyAttributes } from "../entities/createFileEntity";
 import { createLocaleEntity } from "../entities/createLocaleEntity";
@@ -160,8 +160,11 @@ export class FileManager_5_35_0_001_FileData implements DataMigration<FileMigrat
 
                         const execute = () => {
                             return Promise.all(
-                                chunk(items, 200).map(fileChunk => {
-                                    return batchWriteAll({
+                                chunk(items, 200).map(async fileChunk => {
+                                    /**
+                                     * Leave batch write for now.
+                                     */
+                                    return await batchWriteAll({
                                         table: this.newFileEntity.table,
                                         items: fileChunk
                                     });
diff --git a/packages/migrations/src/migrations/5.35.0/003/index.ts b/packages/migrations/src/migrations/5.35.0/003/index.ts
index 562a703c3ba..92d7685ea33 100644
--- a/packages/migrations/src/migrations/5.35.0/003/index.ts
+++ b/packages/migrations/src/migrations/5.35.0/003/index.ts
@@ -1,10 +1,11 @@
 import { Table } from "@webiny/db-dynamodb/toolbox";
 import { DataMigrationContext, PrimaryDynamoTableSymbol } from "@webiny/data-migration";
-import { queryOne, queryAll, batchWriteAll } from "~/utils";
+import { queryAll, queryOne } from "~/utils";
 import { createTenantEntity } from "./createTenantEntity";
 import { createLegacyUserEntity, createUserEntity, getUserData } from "./createUserEntity";
-import { makeInjectable, inject } from "@webiny/ioc";
+import { inject, makeInjectable } from "@webiny/ioc";
 import { executeWithRetry } from "@webiny/utils";
+import { createEntityWriteBatch } from "@webiny/db-dynamodb";
 
 export class AdminUsers_5_35_0_003 {
     private readonly newUserEntity: ReturnType<typeof createUserEntity>;
@@ -73,24 +74,27 @@ export class AdminUsers_5_35_0_003 {
                 continue;
             }
 
-            const newUsers = users
-                .filter(user => !user.data)
-                .map(user => {
-                    return this.newUserEntity.putBatch({
-                        PK: `T#${tenant.id}#ADMIN_USER#${user.id}`,
-                        SK: "A",
-                        GSI1_PK: `T#${tenant.id}#ADMIN_USERS`,
-                        GSI1_SK: user.email,
-                        TYPE: "adminUsers.user",
-                        ...getUserData(user),
-                        // Move all data to a `data` envelope
-                        data: getUserData(user)
-                    });
-                });
+            const newUsersEntityBatch = createEntityWriteBatch({
+                entity: this.newUserEntity,
+                put: users
+                    .filter(user => !user.data)
+                    .map(user => {
+                        return {
+                            PK: `T#${tenant.id}#ADMIN_USER#${user.id}`,
+                            SK: "A",
+                            GSI1_PK: `T#${tenant.id}#ADMIN_USERS`,
+                            GSI1_SK: user.email,
+                            TYPE: "adminUsers.user",
+                            ...getUserData(user),
+                            // Move all data to a `data` envelope
+                            data: getUserData(user)
+                        };
+                    })
+            });
 
-            await executeWithRetry(() =>
-                batchWriteAll({ table: this.newUserEntity.table, items: newUsers })
-            );
+            await executeWithRetry(async () => {
+                return await newUsersEntityBatch.execute();
+            });
         }
     }
 }
diff --git a/packages/migrations/src/migrations/5.35.0/005/index.ts b/packages/migrations/src/migrations/5.35.0/005/index.ts
index f02cd1e77c8..6b32acd2168 100644
--- a/packages/migrations/src/migrations/5.35.0/005/index.ts
+++ b/packages/migrations/src/migrations/5.35.0/005/index.ts
@@ -1,14 +1,15 @@
 import { Table } from "@webiny/db-dynamodb/toolbox";
-import { makeInjectable, inject } from "@webiny/ioc";
+import { inject, makeInjectable } from "@webiny/ioc";
 import { DataMigrationContext, PrimaryDynamoTableSymbol } from "@webiny/data-migration";
-import { queryAll, batchWriteAll } from "~/utils";
+import { queryAll } from "~/utils";
 import { createModelEntity } from "./createModelEntity";
 import { createTenantEntity } from "./createTenantEntity";
 import { createLocaleEntity } from "./createLocaleEntity";
-import { Tenant, I18NLocale, CmsModel } from "./types";
+import { CmsModel, I18NLocale, Tenant } from "./types";
 import pluralize from "pluralize";
 import upperFirst from "lodash/upperFirst";
 import camelCase from "lodash/camelCase";
+import { createEntityWriteBatch } from "@webiny/db-dynamodb";
 
 const createSingularApiName = (model: CmsModel) => {
     return upperFirst(camelCase(model.modelId));
@@ -91,22 +92,23 @@ export class CmsModels_5_35_0_005 {
             return;
         }
 
-        const items = models.map(model => {
-            return this.modelEntity.putBatch({
-                ...model,
-                /**
-                 * Add singular and plural API names.
-                 */
-                singularApiName: createSingularApiName(model),
-                pluralApiName: createPluralApiName(model)
-            });
+        const entityBatch = createEntityWriteBatch({
+            entity: this.modelEntity,
+            put: models.map(model => {
+                return {
+                    ...model,
+                    /**
+                     * Add singular and plural API names.
+                     */
+                    singularApiName: createSingularApiName(model),
+                    pluralApiName: createPluralApiName(model)
+                };
+            })
         });
-        logger.info(`Updating total of ${items.length} models.`);
 
-        await batchWriteAll({
-            table: this.modelEntity.table,
-            items
-        });
+        logger.info(`Updating total of ${entityBatch.total} models.`);
+
+        await entityBatch.execute();
         logger.info("Updated all the models.");
     }
 
diff --git a/packages/migrations/src/migrations/5.37.0/002/ddb-es/index.ts b/packages/migrations/src/migrations/5.37.0/002/ddb-es/index.ts
index f673b58b039..429fc900dee 100644
--- a/packages/migrations/src/migrations/5.37.0/002/ddb-es/index.ts
+++ b/packages/migrations/src/migrations/5.37.0/002/ddb-es/index.ts
@@ -272,15 +272,15 @@ export class CmsEntriesRootFolder_5_37_0_002
                     );
                 }
 
-                const execute = () => {
-                    return batchWriteAll({
+                const execute = async () => {
+                    return await batchWriteAll({
                         table: this.ddbEntryEntity.table,
                         items: ddbItems
                     });
                 };
 
-                const executeDdbEs = () => {
-                    return batchWriteAll({
+                const executeDdbEs = async () => {
+                    return await batchWriteAll({
                         table: this.ddbEsEntryEntity.table,
                         items: ddbEsItems
                     });
diff --git a/packages/migrations/src/migrations/5.37.0/003/ddb-es/AcoFolderMigration.ts b/packages/migrations/src/migrations/5.37.0/003/ddb-es/AcoFolderMigration.ts
index 9869ada4a64..1cdc644b628 100644
--- a/packages/migrations/src/migrations/5.37.0/003/ddb-es/AcoFolderMigration.ts
+++ b/packages/migrations/src/migrations/5.37.0/003/ddb-es/AcoFolderMigration.ts
@@ -7,8 +7,6 @@ import { createLocaleEntity } from "../entities/createLocaleEntity";
 import { createTenantEntity } from "../entities/createTenantEntity";
 import { createDdbEntryEntity, createDdbEsEntryEntity } from "../entities/createEntryEntity";
 import {
-    batchWriteAll,
-    BatchWriteItem,
     esFindOne,
     esGetIndexExist,
     esGetIndexName,
@@ -20,6 +18,7 @@ import { CmsEntryAcoFolder, I18NLocale, ListLocalesParams, Tenant } from "../typ
 import { ACO_FOLDER_MODEL_ID, ROOT_FOLDER, UPPERCASE_ROOT_FOLDER } from "../constants";
 import { getElasticsearchLatestEntryData } from "./latestElasticsearchData";
 import { getDecompressedData } from "~/migrations/5.37.0/003/utils/getDecompressedData";
+import { createEntityWriteBatch } from "@webiny/db-dynamodb";
 
 const isGroupMigrationCompleted = (
     status: PrimitiveValue[] | boolean | undefined
@@ -236,8 +235,12 @@ export class AcoRecords_5_37_0_003_AcoFolder
                             `Processing batch #${batch} in group ${groupId} (${folders.length} folders).`
                         );
 
-                        const ddbItems: BatchWriteItem[] = [];
-                        const ddbEsItems: BatchWriteItem[] = [];
+                        const entityBatch = createEntityWriteBatch({
+                            entity: this.ddbEntryEntity
+                        });
+                        const elasticsearchEntityBatch = createEntityWriteBatch({
+                            entity: this.ddbEsEntryEntity
+                        });
 
                         for (const folder of folders) {
                             const folderPk = `T#${tenantId}#L#${localeCode}#CMS#CME#${folder.entryId}`;
@@ -278,10 +281,8 @@ export class AcoRecords_5_37_0_003_AcoFolder
                                 TYPE: "cms.entry"
                             };
 
-                            ddbItems.push(
-                                this.ddbEntryEntity.putBatch(latestDdb),
-                                this.ddbEntryEntity.putBatch(revisionDdb)
-                            );
+                            entityBatch.put(latestDdb);
+                            entityBatch.put(revisionDdb);
 
                             const esLatestRecord = await get<CmsEntryAcoFolderElasticsearchRecord>({
                                 entity: this.ddbEsEntryEntity,
@@ -316,21 +317,15 @@ export class AcoRecords_5_37_0_003_AcoFolder
                                 index: foldersIndexName
                             };
 
-                            ddbEsItems.push(this.ddbEsEntryEntity.putBatch(latestDdbEs));
+                            elasticsearchEntityBatch.put(latestDdbEs);
                         }
 
-                        const executeDdb = () => {
-                            return batchWriteAll({
-                                table: this.ddbEntryEntity.table,
-                                items: ddbItems
-                            });
+                        const executeDdb = async () => {
+                            return entityBatch.execute();
                         };
 
-                        const executeDdbEs = () => {
-                            return batchWriteAll({
-                                table: this.ddbEsEntryEntity.table,
-                                items: ddbEsItems
-                            });
+                        const executeDdbEs = async () => {
+                            return elasticsearchEntityBatch.execute();
                         };
 
                         await executeWithRetry(executeDdb, {
diff --git a/packages/migrations/src/migrations/5.40.0/001/ddb/index.ts b/packages/migrations/src/migrations/5.40.0/001/ddb/index.ts
index 47166648883..ee0fa247684 100644
--- a/packages/migrations/src/migrations/5.40.0/001/ddb/index.ts
+++ b/packages/migrations/src/migrations/5.40.0/001/ddb/index.ts
@@ -4,18 +4,13 @@ import {
     DataMigrationContext,
     PrimaryDynamoTableSymbol
 } from "@webiny/data-migration";
-import {
-    batchWriteAll,
-    BatchWriteItem,
-    count,
-    ddbQueryAllWithCallback,
-    forEachTenantLocale
-} from "~/utils";
+import { count, ddbQueryAllWithCallback, forEachTenantLocale } from "~/utils";
 import { inject, makeInjectable } from "@webiny/ioc";
 import { executeWithRetry, generateAlphaNumericId } from "@webiny/utils";
 import { createBlockEntity } from "~/migrations/5.40.0/001/ddb/createBlockEntity";
 import { ContentElement, PageBlock } from "./types";
 import { compress, decompress } from "./compression";
+import { createEntityWriteBatch } from "@webiny/db-dynamodb";
 
 const isGroupMigrationCompleted = (status: boolean | undefined): status is boolean => {
     return typeof status === "boolean";
@@ -99,6 +94,10 @@ export class PbUniqueBlockElementIds_5_40_0_001 implements DataMigration {
                             `Processing batch #${batch} in group ${groupId} (${blocks.length} blocks).`
                         );
 
+                        const entityBatch = createEntityWriteBatch({
+                            entity: this.blockEntity
+                        });
+
                         const items = await Promise.all(
                             blocks.map(async block => {
                                 const newContent = await this.generateElementIds(block);
@@ -106,18 +105,17 @@ export class PbUniqueBlockElementIds_5_40_0_001 implements DataMigration {
                                     return null;
                                 }
 
-                                return this.blockEntity.putBatch({
+                                const item = {
                                     ...block,
                                     content: newContent
-                                });
+                                };
+                                entityBatch.put(item);
+                                return item;
                             })
                         );
 
-                        const execute = () => {
-                            return batchWriteAll({
-                                table: this.blockEntity.table,
-                                items: items.filter(Boolean) as BatchWriteItem[]
-                            });
+                        const execute = async () => {
+                            return await entityBatch.execute();
                         };
 
                         await executeWithRetry(execute, {
diff --git a/packages/migrations/src/migrations/5.41.0/001/index.ts b/packages/migrations/src/migrations/5.41.0/001/index.ts
index 6cec0e2917d..070146eb9f6 100644
--- a/packages/migrations/src/migrations/5.41.0/001/index.ts
+++ b/packages/migrations/src/migrations/5.41.0/001/index.ts
@@ -1,10 +1,11 @@
 import { Table } from "@webiny/db-dynamodb/toolbox";
 import { DataMigrationContext, PrimaryDynamoTableSymbol } from "@webiny/data-migration";
-import { queryOne, queryAll, batchWriteAll } from "~/utils";
+import { queryAll, queryOne } from "~/utils";
 import { createTenantEntity } from "./createTenantEntity";
 import { createUserEntity } from "./createUserEntity";
-import { makeInjectable, inject } from "@webiny/ioc";
+import { inject, makeInjectable } from "@webiny/ioc";
 import { executeWithRetry } from "@webiny/utils";
+import { createEntityWriteBatch } from "@webiny/db-dynamodb";
 
 export class AdminUsers_5_41_0_001 {
     private readonly newUserEntity: ReturnType<typeof createUserEntity>;
@@ -71,22 +72,25 @@ export class AdminUsers_5_41_0_001 {
                 continue;
             }
 
-            const newUsers = users
-                .filter(user => !Array.isArray(user.data.groups))
-                .map(user => {
-                    return this.newUserEntity.putBatch({
-                        ...user,
-                        data: {
-                            ...user.data,
-                            groups: [user.data.group].filter(Boolean),
-                            teams: [user.data.team].filter(Boolean)
-                        }
-                    });
-                });
+            const newUsersEntityBatch = createEntityWriteBatch({
+                entity: this.newUserEntity,
+                put: users
+                    .filter(user => !Array.isArray(user.data.groups))
+                    .map(user => {
+                        return {
+                            ...user,
+                            data: {
+                                ...user.data,
+                                groups: [user.data.group].filter(Boolean),
+                                teams: [user.data.team].filter(Boolean)
+                            }
+                        };
+                    })
+            });
 
-            await executeWithRetry(() =>
-                batchWriteAll({ table: this.newUserEntity.table, items: newUsers })
-            );
+            await executeWithRetry(async () => {
+                return await newUsersEntityBatch.execute();
+            });
         }
     }
 }
diff --git a/packages/migrations/src/utils/forEachTenantLocale.ts b/packages/migrations/src/utils/forEachTenantLocale.ts
index a88284551a9..bd1f7cb13c7 100644
--- a/packages/migrations/src/utils/forEachTenantLocale.ts
+++ b/packages/migrations/src/utils/forEachTenantLocale.ts
@@ -1,8 +1,18 @@
 import { createLocaleEntity, createTenantEntity, queryAll } from "~/utils";
-import { I18NLocale, Tenant } from "~/migrations/5.37.0/003/types";
 import { Table } from "@webiny/db-dynamodb/toolbox";
 import { Logger } from "@webiny/logger";
 
+export interface Tenant {
+    data: {
+        id: string;
+        name: string;
+    };
+}
+
+export interface I18NLocale {
+    code: string;
+}
+
 type ForEachTenantLocaleCallback = (params: {
     tenantId: string;
     localeCode: string;
diff --git a/yarn.lock b/yarn.lock
index 9474c8c091f..08a8f9dba39 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -14716,7 +14716,6 @@ __metadata:
     dataloader: ^2.0.0
     jest: ^29.7.0
     jest-dynalite: ^3.2.0
-    lodash: ^4.17.21
     rimraf: ^5.0.5
     ttypescript: ^1.5.12
     typescript: 4.9.5
@@ -17302,7 +17301,6 @@ __metadata:
     "@webiny/cli": 0.0.0
     "@webiny/db": 0.0.0
     "@webiny/error": 0.0.0
-    "@webiny/handler-db": 0.0.0
     "@webiny/plugins": 0.0.0
     "@webiny/project-utils": 0.0.0
     "@webiny/utils": 0.0.0

From 9b7ddad4a32970dc7d5f655bd7c69177f9a2facf Mon Sep 17 00:00:00 2001
From: adrians5j <adrian@webiny.com>
Date: Wed, 18 Dec 2024 13:56:58 +0100
Subject: [PATCH 18/52] ci: add `/jest` command [no ci]

---
 .github/workflows/pullRequestsCommandJest.yml | 466 ++++++++++++++++++
 .../wac/pullRequestsCommandJest.wac.ts        | 173 +++++++
 2 files changed, 639 insertions(+)
 create mode 100644 .github/workflows/pullRequestsCommandJest.yml
 create mode 100644 .github/workflows/wac/pullRequestsCommandJest.wac.ts

diff --git a/.github/workflows/pullRequestsCommandJest.yml b/.github/workflows/pullRequestsCommandJest.yml
new file mode 100644
index 00000000000..9a5a2fe264e
--- /dev/null
+++ b/.github/workflows/pullRequestsCommandJest.yml
@@ -0,0 +1,466 @@
+# This file was automatically generated by github-actions-wac.
+# DO NOT MODIFY IT BY HAND. Instead, modify the source *.wac.ts file(s)
+# and run "github-actions-wac build" (or "ghawac build") to regenerate this file.
+# For more information, run "github-actions-wac --help".
+name: Pull Requests Command - Jest
+"on": issue_comment
+env:
+  NODE_OPTIONS: "--max_old_space_size=4096"
+  AWS_REGION: eu-central-1
+jobs:
+  checkComment:
+    name: Check comment for /jest
+    if: ${{ github.event.issue.pull_request }}
+    steps:
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 20
+      - name: Check for Command
+        id: command
+        uses: xt0rted/slash-command-action@v2
+        with:
+          repo-token: ${{ secrets.GITHUB_TOKEN }}
+          command: jest
+          reaction: "true"
+          reaction-type: eyes
+          allow-edits: "false"
+          permission-level: write
+      - name: Create comment
+        uses: peter-evans/create-or-update-comment@v2
+        with:
+          issue-number: ${{ github.event.issue.number }}
+          body: >-
+            Jest tests have been initiated (for more information, click
+            [here](https://github.com/webiny/webiny-js/actions/runs/${{
+            github.run_id }})). :sparkles:
+    runs-on: ubuntu-latest
+    env:
+      NODE_OPTIONS: "--max_old_space_size=4096"
+      YARN_ENABLE_IMMUTABLE_INSTALLS: false
+  validateWorkflows:
+    name: Validate workflows
+    steps:
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 20
+      - uses: actions/checkout@v4
+      - name: Install dependencies
+        run: yarn --immutable
+      - name: Validate
+        run: npx github-actions-wac validate
+    needs: checkComment
+    runs-on: ubuntu-latest
+    env:
+      NODE_OPTIONS: "--max_old_space_size=4096"
+      YARN_ENABLE_IMMUTABLE_INSTALLS: false
+  baseBranch:
+    needs: checkComment
+    name: Get base branch
+    outputs:
+      base-branch: ${{ steps.base-branch.outputs.base-branch }}
+    steps:
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 20
+      - uses: actions/checkout@v4
+      - name: Get base branch
+        id: base-branch
+        env:
+          GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
+        run: >-
+          echo "base-branch=$(gh pr view ${{ github.event.issue.number }} --json
+          baseRefName -q .baseRefName)" >> $GITHUB_OUTPUT
+    runs-on: ubuntu-latest
+    env:
+      NODE_OPTIONS: "--max_old_space_size=4096"
+      YARN_ENABLE_IMMUTABLE_INSTALLS: false
+  constants:
+    needs: baseBranch
+    name: Create constants
+    outputs:
+      global-cache-key: ${{ steps.global-cache-key.outputs.global-cache-key }}
+      run-cache-key: ${{ steps.run-cache-key.outputs.run-cache-key }}
+    steps:
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 20
+      - name: Create global cache key
+        id: global-cache-key
+        run: >-
+          echo "global-cache-key=${{ needs.baseBranch.outputs.base-branch }}-${{
+          runner.os }}-$(/bin/date -u "+%m%d")-${{ vars.RANDOM_CACHE_KEY_SUFFIX
+          }}" >> $GITHUB_OUTPUT
+      - name: Create workflow run cache key
+        id: run-cache-key
+        run: >-
+          echo "run-cache-key=${{ github.run_id }}-${{ github.run_attempt }}-${{
+          vars.RANDOM_CACHE_KEY_SUFFIX }}" >> $GITHUB_OUTPUT
+    runs-on: ubuntu-latest
+    env:
+      NODE_OPTIONS: "--max_old_space_size=4096"
+      YARN_ENABLE_IMMUTABLE_INSTALLS: false
+  build:
+    name: Build
+    needs:
+      - baseBranch
+      - constants
+    runs-on: webiny-build-packages
+    steps:
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 20
+      - uses: actions/checkout@v4
+        with:
+          path: ${{ needs.baseBranch.outputs.base-branch }}
+      - name: Checkout Pull Request
+        working-directory: ${{ needs.baseBranch.outputs.base-branch }}
+        run: gh pr checkout ${{ github.event.issue.number }}
+        env:
+          GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
+      - uses: actions/cache@v4
+        with:
+          path: ${{ needs.baseBranch.outputs.base-branch }}/.yarn/cache
+          key: yarn-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
+      - uses: actions/cache@v4
+        with:
+          path: ${{ needs.baseBranch.outputs.base-branch }}/.webiny/cached-packages
+          key: ${{ needs.constants.outputs.global-cache-key }}
+      - name: Install dependencies
+        run: yarn --immutable
+        working-directory: ${{ needs.baseBranch.outputs.base-branch }}
+      - name: Build packages
+        run: yarn build:quick
+        working-directory: ${{ needs.baseBranch.outputs.base-branch }}
+      - uses: actions/cache@v4
+        with:
+          path: ${{ needs.baseBranch.outputs.base-branch }}/.webiny/cached-packages
+          key: ${{ needs.constants.outputs.run-cache-key }}
+    env:
+      NODE_OPTIONS: "--max_old_space_size=4096"
+      YARN_ENABLE_IMMUTABLE_INSTALLS: false
+  jestTestsNoStorage:
+    needs:
+      - constants
+      - build
+    name: ${{ matrix.package.cmd }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os:
+          - ubuntu-latest
+        node:
+          - 20
+        package: >-
+          ${{
+          fromJson('[{"cmd":"packages/api","packageName":"api","id":"806497aaa729e8d39f59792bcfb12b26"},{"cmd":"packages/api-admin-settings","packageName":"api-admin-settings","id":"31140e7ea9283c9db32ec5f905ce2a1e"},{"cmd":"packages/api-authentication","packageName":"api-authentication","id":"0eaf9f853f122e4ab215bf49d39f3edc"},{"cmd":"packages/api-authentication-cognito","packageName":"api-authentication-cognito","id":"dfb5e1fcea213538a9730314cb5e7d06"},{"cmd":"packages/api-headless-cms-ddb","packageName":"api-headless-cms-ddb","id":"5333e1fe6c2b8f5bbcb101a446419c3e"},{"cmd":"packages/api-record-locking","packageName":"api-record-locking","id":"9340c019a5369ea1aa55f7ed28b09f48"},{"cmd":"packages/api-wcp","packageName":"api-wcp","id":"77ff8a0a075e8d9f7e25001ea64c6c9e"},{"cmd":"packages/api-websockets","packageName":"api-websockets","id":"fd704b97c31f78a886b342babd344d33"},{"cmd":"packages/app-aco","packageName":"app-aco","id":"dddb66beffe2e54804d5bdedd2b423cb"},{"cmd":"packages/app-admin","packageName":"app-admin","id":"53bbef747a26e831904585bcfdd845f7"},{"cmd":"packages/cwp-template-aws","packageName":"cwp-template-aws","id":"846572f41c9427974a577bb95257d019"},{"cmd":"packages/data-migration","packageName":"data-migration","id":"294257fffed0174f169b2c812e16258e"},{"cmd":"packages/db-dynamodb","packageName":"db-dynamodb","id":"5cb733de265d7bbda981fce60f2a8962"},{"cmd":"packages/form","packageName":"form","id":"5707e699d8a4d3b8ee1954c070a50617"},{"cmd":"packages/handler","packageName":"handler","id":"1dad17bbf61657b4308250e8293cb5dd"},{"cmd":"packages/handler-aws","packageName":"handler-aws","id":"2a5bd44c5f2a4290c43f9021bbc705a5"},{"cmd":"packages/handler-graphql","packageName":"handler-graphql","id":"74884166fb2bf383da482fb78b18b704"},{"cmd":"packages/handler-logs","packageName":"handler-logs","id":"ca9a7e2ed32de50aff66c839f0003352"},{"cmd":"packages/ioc","packageName":"ioc","id":"af22b6d7d245321d64d4b714d03ef3e1"},{"cmd":"packages/lexical-converter","packageName":"lexical-converter","id":"52e3bb3ea633bd27d5bab8be976cd16f"},{"cmd":"packages/plugins","packageName":"plugins","id":"c91537eaa40845d816d0d9f39e66018b"},{"cmd":"packages/pubsub","packageName":"pubsub","id":"fc14c28c51c537a7d9edd33d73ae29e2"},{"cmd":"packages/react-composition","packageName":"react-composition","id":"428b8a3187fe275cb76da6bad0ba3918"},{"cmd":"packages/react-properties","packageName":"react-properties","id":"7578e63dcaa1ac66fed4a8dd936a9285"},{"cmd":"packages/react-rich-text-lexical-renderer","packageName":"react-rich-text-lexical-renderer","id":"452451b34eb7e0134e99b0706e5eb076"},{"cmd":"packages/utils","packageName":"utils","id":"696ceb17e38e4a274d4a149d24513b78"},{"cmd":"packages/validation","packageName":"validation","id":"9c68da33792a1214ae45e040a2830cd7"}]')
+          }}
+    runs-on: ${{ matrix.os }}
+    env:
+      NODE_OPTIONS: "--max_old_space_size=4096"
+      YARN_ENABLE_IMMUTABLE_INSTALLS: false
+      AWS_REGION: eu-central-1
+    steps:
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 20
+      - uses: actions/checkout@v4
+        with:
+          path: ${{ needs.baseBranch.outputs.base-branch }}
+      - uses: actions/cache@v4
+        with:
+          path: ${{ needs.baseBranch.outputs.base-branch }}/.yarn/cache
+          key: yarn-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
+      - uses: actions/cache@v4
+        with:
+          path: ${{ needs.baseBranch.outputs.base-branch }}/.webiny/cached-packages
+          key: ${{ needs.constants.outputs.run-cache-key }}
+      - name: Install dependencies
+        run: yarn --immutable
+        working-directory: ${{ needs.baseBranch.outputs.base-branch }}
+      - name: Build packages
+        run: yarn build:quick
+        working-directory: ${{ needs.baseBranch.outputs.base-branch }}
+      - name: Run tests
+        run: yarn test ${{ matrix.package.cmd }}
+        working-directory: ${{ needs.baseBranch.outputs.base-branch }}
+  jestTestsDdb:
+    needs:
+      - constants
+      - build
+    name: ${{ matrix.package.cmd }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os:
+          - ubuntu-latest
+        node:
+          - 20
+        package: >-
+          ${{ fromJson('[{"cmd":"packages/api-aco
+          --storage=ddb","storage":"ddb","packageName":"api-aco","id":"5595b4f3460fb2a019066177bd6489f3"},{"cmd":"packages/api-apw
+          --storage=ddb","storage":"ddb","packageName":"api-apw","id":"04462239e1f3509b08f511de460971ec"},{"cmd":"packages/api-audit-logs
+          --storage=ddb","storage":"ddb","packageName":"api-audit-logs","id":"47680aa68a1a3951f1117c736e150e45"},{"cmd":"packages/api-file-manager
+          --storage=ddb","storage":"ddb","packageName":"api-file-manager","id":"9b6eee1ff7cbf9a3d367818705cc4189"},{"cmd":"packages/api-form-builder
+          --storage=ddb","storage":"ddb","packageName":"api-form-builder","id":"980a9aebb5ec0cab057422364a60493b"},{"cmd":"packages/api-headless-cms
+          --storage=ddb
+          --shard=1/6","storage":"ddb","packageName":"api-headless-cms","id":"70476469f4407a455237133406a37a4b"},{"cmd":"packages/api-headless-cms
+          --storage=ddb
+          --shard=2/6","storage":"ddb","packageName":"api-headless-cms","id":"0eba11dcf36fd00e737a630f40567e85"},{"cmd":"packages/api-headless-cms
+          --storage=ddb
+          --shard=3/6","storage":"ddb","packageName":"api-headless-cms","id":"8c15e662d10ad6272ac557515e39d4cd"},{"cmd":"packages/api-headless-cms
+          --storage=ddb
+          --shard=4/6","storage":"ddb","packageName":"api-headless-cms","id":"3b14c43cd5971ad2945b1f0e87970e20"},{"cmd":"packages/api-headless-cms
+          --storage=ddb
+          --shard=5/6","storage":"ddb","packageName":"api-headless-cms","id":"a71716169299cfee9996f4344c84616f"},{"cmd":"packages/api-headless-cms
+          --storage=ddb
+          --shard=6/6","storage":"ddb","packageName":"api-headless-cms","id":"26f0b825b771340ca981858d86bd1f42"},{"cmd":"packages/api-headless-cms-aco
+          --storage=ddb","storage":"ddb","packageName":"api-headless-cms-aco","id":"718c110b004c59ed7d13cbcc875a6b64"},{"cmd":"packages/api-headless-cms-bulk-actions
+          --storage=ddb","storage":"ddb","packageName":"api-headless-cms-bulk-actions","id":"00c0a57737502f28c304015d2d1ba442"},{"cmd":"packages/api-headless-cms-import-export
+          --storage=ddb","storage":"ddb","packageName":"api-headless-cms-import-export","id":"e9052e7c40171aeb43ce089fdfbbe3c8"},{"cmd":"packages/api-i18n
+          --storage=ddb","storage":"ddb","packageName":"api-i18n","id":"943e15fe21c847b164f9413f8baf97b7"},{"cmd":"packages/api-mailer
+          --storage=ddb","storage":"ddb","packageName":"api-mailer","id":"2cc1dc707a39e72f4e5d9a140677ca39"},{"cmd":"packages/api-page-builder
+          --storage=ddb
+          --shard=1/6","storage":"ddb","packageName":"api-page-builder","id":"b2a30dfaf230076ce7120c55eb581d32"},{"cmd":"packages/api-page-builder
+          --storage=ddb
+          --shard=2/6","storage":"ddb","packageName":"api-page-builder","id":"c58e2f120653e8bd68475c16de4434c5"},{"cmd":"packages/api-page-builder
+          --storage=ddb
+          --shard=3/6","storage":"ddb","packageName":"api-page-builder","id":"808cb2da8e70bf84a24de2ab7ed27c24"},{"cmd":"packages/api-page-builder
+          --storage=ddb
+          --shard=4/6","storage":"ddb","packageName":"api-page-builder","id":"6f95134a56bea87da59d4c7d56846d72"},{"cmd":"packages/api-page-builder
+          --storage=ddb
+          --shard=5/6","storage":"ddb","packageName":"api-page-builder","id":"918eb8cb9d4046da9d38962b12e8ace6"},{"cmd":"packages/api-page-builder
+          --storage=ddb
+          --shard=6/6","storage":"ddb","packageName":"api-page-builder","id":"45bc3d824b38bd2770f1d4ba357387f9"},{"cmd":"packages/api-page-builder-aco
+          --storage=ddb","storage":"ddb","packageName":"api-page-builder-aco","id":"48281621c024ae9bbd0f79da5f6f4867"},{"cmd":"packages/api-page-builder-import-export
+          --storage=ddb","storage":"ddb","packageName":"api-page-builder-import-export","id":"8540085b59af85d1fd82b37b9e890704"},{"cmd":"packages/api-prerendering-service
+          --storage=ddb","storage":"ddb","packageName":"api-prerendering-service","id":"a2831c88465244dc03f188f4a40e4d63"},{"cmd":"packages/api-security
+          --storage=ddb","storage":"ddb","packageName":"api-security","id":"0a065366763b713fb016c43ce21e77b9"},{"cmd":"packages/api-security-cognito
+          --storage=ddb","storage":"ddb","packageName":"api-security-cognito","id":"0787967fe56689618106e6c64e784bff"},{"cmd":"packages/api-serverless-cms
+          --storage=ddb","storage":"ddb","packageName":"api-serverless-cms","id":"b660572a629aa6e9191829fe7bfd33cc"},{"cmd":"packages/api-tenancy
+          --storage=ddb","storage":"ddb","packageName":"api-tenancy","id":"0c81e56d64e97e6b563965250f04ed34"},{"cmd":"packages/api-tenant-manager
+          --storage=ddb","storage":"ddb","packageName":"api-tenant-manager","id":"4b93a028b8055553c3443a45b38079e9"},{"cmd":"packages/tasks
+          --storage=ddb","storage":"ddb","packageName":"tasks","id":"925ba761b5995e8a8b980c0789034b3c"}]')
+          }}
+    runs-on: ${{ matrix.os }}
+    env:
+      NODE_OPTIONS: "--max_old_space_size=4096"
+      YARN_ENABLE_IMMUTABLE_INSTALLS: false
+      AWS_REGION: eu-central-1
+    steps:
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 20
+      - uses: actions/checkout@v4
+        with:
+          path: ${{ needs.baseBranch.outputs.base-branch }}
+      - uses: actions/cache@v4
+        with:
+          path: ${{ needs.baseBranch.outputs.base-branch }}/.yarn/cache
+          key: yarn-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
+      - uses: actions/cache@v4
+        with:
+          path: ${{ needs.baseBranch.outputs.base-branch }}/.webiny/cached-packages
+          key: ${{ needs.constants.outputs.run-cache-key }}
+      - name: Install dependencies
+        run: yarn --immutable
+        working-directory: ${{ needs.baseBranch.outputs.base-branch }}
+      - name: Build packages
+        run: yarn build:quick
+        working-directory: ${{ needs.baseBranch.outputs.base-branch }}
+      - name: Run tests
+        run: yarn test ${{ matrix.package.cmd }}
+        working-directory: ${{ needs.baseBranch.outputs.base-branch }}
+  jestTestsDdbEs:
+    needs:
+      - constants
+      - build
+    name: ${{ matrix.package.cmd }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os:
+          - ubuntu-latest
+        node:
+          - 20
+        package: >-
+          ${{ fromJson('[{"cmd":"packages/api-aco
+          --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-aco","id":"8f23ec33f547aa62236f5c71115688d6"},{"cmd":"packages/api-audit-logs
+          --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-audit-logs","id":"a292444cd9100f78d8fc196274393ea8"},{"cmd":"packages/api-dynamodb-to-elasticsearch
+          --storage=ddb-es,ddb","storage":["ddb-es"],"packageName":"api-dynamodb-to-elasticsearch","id":"e2c325f0940ba5fb5a891a8cf74fca61"},{"cmd":"packages/api-elasticsearch
+          --storage=ddb-es,ddb","storage":["ddb-es"],"packageName":"api-elasticsearch","id":"5963079c60b96202bbaf2a802ad14383"},{"cmd":"packages/api-elasticsearch-tasks
+          --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-elasticsearch-tasks","id":"d81ad1d024a8746cc440e2e548770f8f"},{"cmd":"packages/api-file-manager
+          --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-file-manager","id":"d6f293add4a252b96cbd770ab6e80557"},{"cmd":"packages/api-form-builder
+          --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-form-builder","id":"3753bde0144d808eb15c755b7176386c"},{"cmd":"packages/api-form-builder-so-ddb-es
+          --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-form-builder-so-ddb-es","id":"be1748722ce53a7383696bdc9aecb36e"},{"cmd":"packages/api-headless-cms
+          --storage=ddb-es,ddb
+          --shard=1/6","storage":"ddb-es","packageName":"api-headless-cms","id":"c9e8cf197d213d99f54ae218b027db43"},{"cmd":"packages/api-headless-cms
+          --storage=ddb-es,ddb
+          --shard=2/6","storage":"ddb-es","packageName":"api-headless-cms","id":"0db69460c7bcc2bd54f21ae32c2436a0"},{"cmd":"packages/api-headless-cms
+          --storage=ddb-es,ddb
+          --shard=3/6","storage":"ddb-es","packageName":"api-headless-cms","id":"13763c404c6788aa580d8b9fa8f52239"},{"cmd":"packages/api-headless-cms
+          --storage=ddb-es,ddb
+          --shard=4/6","storage":"ddb-es","packageName":"api-headless-cms","id":"795fb79efa47ed2c7b14b1601b03db21"},{"cmd":"packages/api-headless-cms
+          --storage=ddb-es,ddb
+          --shard=5/6","storage":"ddb-es","packageName":"api-headless-cms","id":"775a20e72e2f9e3db4c119b08dca9858"},{"cmd":"packages/api-headless-cms
+          --storage=ddb-es,ddb
+          --shard=6/6","storage":"ddb-es","packageName":"api-headless-cms","id":"d9e94bb347222577c3a3c8ea3cc41e47"},{"cmd":"packages/api-headless-cms-aco
+          --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-headless-cms-aco","id":"873cd623b92712713e58e7dc6ddbe5d9"},{"cmd":"packages/api-headless-cms-bulk-actions
+          --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-headless-cms-bulk-actions","id":"d57a9e2a64e475f4629a14f4e1130e78"},{"cmd":"packages/api-headless-cms-ddb-es
+          --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-headless-cms-ddb-es","id":"f64e01fd77d4d1c22803e1523560b07c"},{"cmd":"packages/api-headless-cms-es-tasks
+          --storage=ddb-es,ddb","storage":["ddb-es"],"packageName":"api-headless-cms-es-tasks","id":"f857b5e4a7381a7f10eadef6ec83d9e0"},{"cmd":"packages/api-headless-cms-import-export
+          --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-headless-cms-import-export","id":"fa2cbb7997de447c87e3f1b646008711"},{"cmd":"packages/api-mailer
+          --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-mailer","id":"ccc077215f734fbec817d90fdb04d423"},{"cmd":"packages/api-page-builder
+          --storage=ddb-es,ddb
+          --shard=1/6","storage":"ddb-es","packageName":"api-page-builder","id":"a9d5f7851f0b921677df8521ff899f86"},{"cmd":"packages/api-page-builder
+          --storage=ddb-es,ddb
+          --shard=2/6","storage":"ddb-es","packageName":"api-page-builder","id":"d6c00270cbcfa826dab79e8c703c9eb5"},{"cmd":"packages/api-page-builder
+          --storage=ddb-es,ddb
+          --shard=3/6","storage":"ddb-es","packageName":"api-page-builder","id":"b407ab6f87871e108480b0fa3bc17902"},{"cmd":"packages/api-page-builder
+          --storage=ddb-es,ddb
+          --shard=4/6","storage":"ddb-es","packageName":"api-page-builder","id":"9aa4fe8f6e30c49c501003a914b2ca5c"},{"cmd":"packages/api-page-builder
+          --storage=ddb-es,ddb
+          --shard=5/6","storage":"ddb-es","packageName":"api-page-builder","id":"a84a7bf736194196387f2959132abfdd"},{"cmd":"packages/api-page-builder
+          --storage=ddb-es,ddb
+          --shard=6/6","storage":"ddb-es","packageName":"api-page-builder","id":"02927f20dd60108bec8356b6dae55357"},{"cmd":"packages/api-page-builder-aco
+          --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-page-builder-aco","id":"d12985ec4dcdb80af419125d236a73d8"},{"cmd":"packages/api-page-builder-so-ddb-es
+          --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-page-builder-so-ddb-es","id":"911289d4016adf351238298ce5b41ac8"},{"cmd":"packages/api-serverless-cms
+          --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-serverless-cms","id":"3d8f52f5b779b9ded3d746716fed019f"},{"cmd":"packages/migrations
+          --storage=ddb-es,ddb","storage":["ddb-es"],"packageName":"migrations","id":"7262de0ebd8c413fce5cc1428462df1a"},{"cmd":"packages/tasks
+          --storage=ddb-es,ddb","storage":"ddb-es","packageName":"tasks","id":"0c5cd8395d241e54e3488ffcc1c81c26"}]')
+          }}
+    runs-on: ${{ matrix.os }}
+    env:
+      NODE_OPTIONS: "--max_old_space_size=4096"
+      YARN_ENABLE_IMMUTABLE_INSTALLS: false
+      AWS_REGION: eu-central-1
+      AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }}
+      ELASTIC_SEARCH_ENDPOINT: ${{ secrets.ELASTIC_SEARCH_ENDPOINT }}
+      ELASTIC_SEARCH_INDEX_PREFIX: ${{ matrix.package.id }}
+    steps:
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 20
+      - name: Configure AWS Credentials
+        uses: aws-actions/configure-aws-credentials@v4
+        with:
+          role-to-assume: arn:aws:iam::726952677045:role/GitHubActionsWebinyJs
+          aws-region: eu-central-1
+      - uses: actions/checkout@v4
+        with:
+          path: ${{ needs.baseBranch.outputs.base-branch }}
+      - uses: actions/cache@v4
+        with:
+          path: ${{ needs.baseBranch.outputs.base-branch }}/.yarn/cache
+          key: yarn-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
+      - uses: actions/cache@v4
+        with:
+          path: ${{ needs.baseBranch.outputs.base-branch }}/.webiny/cached-packages
+          key: ${{ needs.constants.outputs.run-cache-key }}
+      - name: Install dependencies
+        run: yarn --immutable
+        working-directory: ${{ needs.baseBranch.outputs.base-branch }}
+      - name: Build packages
+        run: yarn build:quick
+        working-directory: ${{ needs.baseBranch.outputs.base-branch }}
+      - name: Run tests
+        run: yarn test ${{ matrix.package.cmd }}
+        working-directory: ${{ needs.baseBranch.outputs.base-branch }}
+    permissions:
+      id-token: write
+  jestTestsDdbOs:
+    needs:
+      - constants
+      - build
+    name: ${{ matrix.package.cmd }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os:
+          - ubuntu-latest
+        node:
+          - 20
+        package: >-
+          ${{ fromJson('[{"cmd":"packages/api-aco
+          --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-aco","id":"e4b1b5ebc172f2657485e41c35ad1cd7"},{"cmd":"packages/api-audit-logs
+          --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-audit-logs","id":"b36aac5f0e34dc4583e5422ae589f1ed"},{"cmd":"packages/api-dynamodb-to-elasticsearch
+          --storage=ddb-os,ddb","storage":["ddb-os"],"packageName":"api-dynamodb-to-elasticsearch","id":"6e0b282c3d135703e52b2c55822d4fb0"},{"cmd":"packages/api-elasticsearch
+          --storage=ddb-os,ddb","storage":["ddb-os"],"packageName":"api-elasticsearch","id":"b0f477d6b209f654714809b318be888e"},{"cmd":"packages/api-elasticsearch-tasks
+          --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-elasticsearch-tasks","id":"580a9577fdbd4a241034a42e1a47dee5"},{"cmd":"packages/api-file-manager
+          --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-file-manager","id":"346430a79981d3e214c87254a08e31b2"},{"cmd":"packages/api-form-builder
+          --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-form-builder","id":"d386cddfd3c366ad9955193dcfe74363"},{"cmd":"packages/api-form-builder-so-ddb-es
+          --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-form-builder-so-ddb-es","id":"6086ced9d7b4412cc438b9e1aefbb976"},{"cmd":"packages/api-headless-cms
+          --storage=ddb-os,ddb
+          --shard=1/6","storage":"ddb-os","packageName":"api-headless-cms","id":"f0851fe3b18a5f4130ae919506f9d68f"},{"cmd":"packages/api-headless-cms
+          --storage=ddb-os,ddb
+          --shard=2/6","storage":"ddb-os","packageName":"api-headless-cms","id":"627bf598869494740bdb3ee340398ed5"},{"cmd":"packages/api-headless-cms
+          --storage=ddb-os,ddb
+          --shard=3/6","storage":"ddb-os","packageName":"api-headless-cms","id":"49c59082ed1d7a79b742944965adff82"},{"cmd":"packages/api-headless-cms
+          --storage=ddb-os,ddb
+          --shard=4/6","storage":"ddb-os","packageName":"api-headless-cms","id":"37865d8ba2366687e25fa61967fe4db9"},{"cmd":"packages/api-headless-cms
+          --storage=ddb-os,ddb
+          --shard=5/6","storage":"ddb-os","packageName":"api-headless-cms","id":"19d0191a992c0a5145674dc0b37d96b6"},{"cmd":"packages/api-headless-cms
+          --storage=ddb-os,ddb
+          --shard=6/6","storage":"ddb-os","packageName":"api-headless-cms","id":"2aade1f8261eacc7d93cc25fa3457fac"},{"cmd":"packages/api-headless-cms-aco
+          --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-headless-cms-aco","id":"aa2c8429c2564549a680db23fe963347"},{"cmd":"packages/api-headless-cms-bulk-actions
+          --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-headless-cms-bulk-actions","id":"a798b4705a7eb9858a51d80b386cf30a"},{"cmd":"packages/api-headless-cms-ddb-es
+          --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-headless-cms-ddb-es","id":"23bea783bb40390ae069dfa4985f97d2"},{"cmd":"packages/api-headless-cms-es-tasks
+          --storage=ddb-os,ddb","storage":["ddb-os"],"packageName":"api-headless-cms-es-tasks","id":"ee446fd78ad6294bbfb3c0689ff2602e"},{"cmd":"packages/api-headless-cms-import-export
+          --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-headless-cms-import-export","id":"6059cf3e78f93525c8ed72ad83b7de1a"},{"cmd":"packages/api-mailer
+          --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-mailer","id":"0ede859b604febdfa78018cdd1067a77"},{"cmd":"packages/api-page-builder
+          --storage=ddb-os,ddb
+          --shard=1/6","storage":"ddb-os","packageName":"api-page-builder","id":"691427cc9c5cb297c68cb2f90d7fcb89"},{"cmd":"packages/api-page-builder
+          --storage=ddb-os,ddb
+          --shard=2/6","storage":"ddb-os","packageName":"api-page-builder","id":"66b65733ec32b2010df792151240cca1"},{"cmd":"packages/api-page-builder
+          --storage=ddb-os,ddb
+          --shard=3/6","storage":"ddb-os","packageName":"api-page-builder","id":"8cdd1f181701f25f8cf9c3fe45b661bd"},{"cmd":"packages/api-page-builder
+          --storage=ddb-os,ddb
+          --shard=4/6","storage":"ddb-os","packageName":"api-page-builder","id":"0956377c7a7550c745e9402b51bdca85"},{"cmd":"packages/api-page-builder
+          --storage=ddb-os,ddb
+          --shard=5/6","storage":"ddb-os","packageName":"api-page-builder","id":"cc194759ab43627005bc21ee7c833a01"},{"cmd":"packages/api-page-builder
+          --storage=ddb-os,ddb
+          --shard=6/6","storage":"ddb-os","packageName":"api-page-builder","id":"b979f8aa837353847942b60e8f4bc057"},{"cmd":"packages/api-page-builder-aco
+          --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-page-builder-aco","id":"a1a7c90d43da1678f254bd4331cf4d55"},{"cmd":"packages/api-page-builder-so-ddb-es
+          --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-page-builder-so-ddb-es","id":"e0236755edb31fc1a6005eb161941bf8"},{"cmd":"packages/api-serverless-cms
+          --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-serverless-cms","id":"28f2386bb4be699710cb574f3401d76b"},{"cmd":"packages/migrations
+          --storage=ddb-os,ddb","storage":["ddb-os"],"packageName":"migrations","id":"3f8965830bbe44499a4bc97baf27e090"},{"cmd":"packages/tasks
+          --storage=ddb-os,ddb","storage":"ddb-os","packageName":"tasks","id":"5eadfa5cc14ec4e8ba87ac3dfb112580"}]')
+          }}
+    runs-on: ${{ matrix.os }}
+    env:
+      NODE_OPTIONS: "--max_old_space_size=4096"
+      YARN_ENABLE_IMMUTABLE_INSTALLS: false
+      AWS_REGION: eu-central-1
+      AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_OPEN_SEARCH_DOMAIN_NAME }}
+      ELASTIC_SEARCH_ENDPOINT: ${{ secrets.OPEN_SEARCH_ENDPOINT }}
+      ELASTIC_SEARCH_INDEX_PREFIX: ${{ matrix.package.id }}
+    steps:
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 20
+      - name: Configure AWS Credentials
+        uses: aws-actions/configure-aws-credentials@v4
+        with:
+          role-to-assume: arn:aws:iam::726952677045:role/GitHubActionsWebinyJs
+          aws-region: eu-central-1
+      - uses: actions/checkout@v4
+        with:
+          path: ${{ needs.baseBranch.outputs.base-branch }}
+      - uses: actions/cache@v4
+        with:
+          path: ${{ needs.baseBranch.outputs.base-branch }}/.yarn/cache
+          key: yarn-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
+      - uses: actions/cache@v4
+        with:
+          path: ${{ needs.baseBranch.outputs.base-branch }}/.webiny/cached-packages
+          key: ${{ needs.constants.outputs.run-cache-key }}
+      - name: Install dependencies
+        run: yarn --immutable
+        working-directory: ${{ needs.baseBranch.outputs.base-branch }}
+      - name: Build packages
+        run: yarn build:quick
+        working-directory: ${{ needs.baseBranch.outputs.base-branch }}
+      - name: Run tests
+        run: yarn test ${{ matrix.package.cmd }}
+        working-directory: ${{ needs.baseBranch.outputs.base-branch }}
+    permissions:
+      id-token: write
diff --git a/.github/workflows/wac/pullRequestsCommandJest.wac.ts b/.github/workflows/wac/pullRequestsCommandJest.wac.ts
new file mode 100644
index 00000000000..87f2f5d49c9
--- /dev/null
+++ b/.github/workflows/wac/pullRequestsCommandJest.wac.ts
@@ -0,0 +1,173 @@
+import { createWorkflow, NormalJob } from "github-actions-wac";
+import {
+    createGlobalBuildCacheSteps,
+    createInstallBuildSteps,
+    createRunBuildCacheSteps,
+    createYarnCacheSteps,
+    withCommonParams
+} from "./steps";
+import {
+    AWS_REGION,
+    BUILD_PACKAGES_RUNNER,
+    listPackagesWithJestTests,
+    NODE_OPTIONS,
+    NODE_VERSION
+} from "./utils";
+import { createJob, createValidateWorkflowsJob } from "./jobs";
+
+// Will print "next" or "dev". Important for caching (via actions/cache).
+const DIR_WEBINY_JS = "${{ needs.baseBranch.outputs.base-branch }}";
+
+const installBuildSteps = createInstallBuildSteps({ workingDirectory: DIR_WEBINY_JS });
+const yarnCacheSteps = createYarnCacheSteps({ workingDirectory: DIR_WEBINY_JS });
+const globalBuildCacheSteps = createGlobalBuildCacheSteps({ workingDirectory: DIR_WEBINY_JS });
+const runBuildCacheSteps = createRunBuildCacheSteps({ workingDirectory: DIR_WEBINY_JS });
+
+const createCheckoutPrSteps = () =>
+    [
+        {
+            name: "Checkout Pull Request",
+            "working-directory": DIR_WEBINY_JS,
+            run: "gh pr checkout ${{ github.event.issue.number }}",
+            env: { GITHUB_TOKEN: "${{ secrets.GH_TOKEN }}" }
+        }
+    ] as NonNullable<NormalJob["steps"]>;
+
+const createJestTestsJob = (storage: string | null) => {
+    const env: Record<string, string> = { AWS_REGION };
+
+    if (storage) {
+        if (storage === "ddb-es") {
+            env["AWS_ELASTIC_SEARCH_DOMAIN_NAME"] = "${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }}";
+            env["ELASTIC_SEARCH_ENDPOINT"] = "${{ secrets.ELASTIC_SEARCH_ENDPOINT }}";
+            env["ELASTIC_SEARCH_INDEX_PREFIX"] = "${{ matrix.package.id }}";
+        } else if (storage === "ddb-os") {
+            // We still use the same environment variables as for "ddb-es" setup, it's
+            // just that the values are read from different secrets.
+            env["AWS_ELASTIC_SEARCH_DOMAIN_NAME"] = "${{ secrets.AWS_OPEN_SEARCH_DOMAIN_NAME }}";
+            env["ELASTIC_SEARCH_ENDPOINT"] = "${{ secrets.OPEN_SEARCH_ENDPOINT }}";
+            env["ELASTIC_SEARCH_INDEX_PREFIX"] = "${{ matrix.package.id }}";
+        }
+    }
+
+    const packages = listPackagesWithJestTests({ storage });
+
+    return createJob({
+        needs: ["constants", "build"],
+        name: "${{ matrix.package.cmd }}",
+        strategy: {
+            "fail-fast": false,
+            matrix: {
+                os: ["ubuntu-latest"],
+                node: [NODE_VERSION],
+                package: "${{ fromJson('" + JSON.stringify(packages) + "') }}"
+            }
+        },
+        "runs-on": "${{ matrix.os }}",
+        env,
+        awsAuth: storage === "ddb-es" || storage === "ddb-os",
+        checkout: { path: DIR_WEBINY_JS },
+        steps: [
+            ...yarnCacheSteps,
+            ...runBuildCacheSteps,
+            ...installBuildSteps,
+            ...withCommonParams(
+                [{ name: "Run tests", run: "yarn test ${{ matrix.package.cmd }}" }],
+                { "working-directory": DIR_WEBINY_JS }
+            )
+        ]
+    });
+};
+
+export const pullRequestsCommandJest = createWorkflow({
+    name: "Pull Requests Command - Jest",
+    on: "issue_comment",
+    env: {
+        NODE_OPTIONS,
+        AWS_REGION
+    },
+    jobs: {
+        checkComment: createJob({
+            name: `Check comment for /jest`,
+            if: "${{ github.event.issue.pull_request }}",
+            checkout: false,
+            steps: [
+                {
+                    name: "Check for Command",
+                    id: "command",
+                    uses: "xt0rted/slash-command-action@v2",
+                    with: {
+                        "repo-token": "${{ secrets.GITHUB_TOKEN }}",
+                        command: "jest",
+                        reaction: "true",
+                        "reaction-type": "eyes",
+                        "allow-edits": "false",
+                        "permission-level": "write"
+                    }
+                },
+                {
+                    name: "Create comment",
+                    uses: "peter-evans/create-or-update-comment@v2",
+                    with: {
+                        "issue-number": "${{ github.event.issue.number }}",
+                        body: "Jest tests have been initiated (for more information, click [here](https://github.com/webiny/webiny-js/actions/runs/${{ github.run_id }})). :sparkles:"
+                    }
+                }
+            ]
+        }),
+        validateWorkflows: createValidateWorkflowsJob({ needs: "checkComment" }),
+        baseBranch: createJob({
+            needs: "checkComment",
+            name: "Get base branch",
+            outputs: {
+                "base-branch": "${{ steps.base-branch.outputs.base-branch }}"
+            },
+            steps: [
+                {
+                    name: "Get base branch",
+                    id: "base-branch",
+                    env: { GITHUB_TOKEN: "${{ secrets.GH_TOKEN }}" },
+                    run: 'echo "base-branch=$(gh pr view ${{ github.event.issue.number }} --json baseRefName -q .baseRefName)" >> $GITHUB_OUTPUT'
+                }
+            ]
+        }),
+        constants: createJob({
+            needs: "baseBranch",
+            name: "Create constants",
+            outputs: {
+                "global-cache-key": "${{ steps.global-cache-key.outputs.global-cache-key }}",
+                "run-cache-key": "${{ steps.run-cache-key.outputs.run-cache-key }}"
+            },
+            checkout: false,
+            steps: [
+                {
+                    name: "Create global cache key",
+                    id: "global-cache-key",
+                    run: `echo "global-cache-key=\${{ needs.baseBranch.outputs.base-branch }}-\${{ runner.os }}-$(/bin/date -u "+%m%d")-\${{ vars.RANDOM_CACHE_KEY_SUFFIX }}" >> $GITHUB_OUTPUT`
+                },
+                {
+                    name: "Create workflow run cache key",
+                    id: "run-cache-key",
+                    run: 'echo "run-cache-key=${{ github.run_id }}-${{ github.run_attempt }}-${{ vars.RANDOM_CACHE_KEY_SUFFIX }}" >> $GITHUB_OUTPUT'
+                }
+            ]
+        }),
+        build: createJob({
+            name: "Build",
+            needs: ["baseBranch", "constants"],
+            checkout: { path: DIR_WEBINY_JS },
+            "runs-on": BUILD_PACKAGES_RUNNER,
+            steps: [
+                ...createCheckoutPrSteps(),
+                ...yarnCacheSteps,
+                ...globalBuildCacheSteps,
+                ...installBuildSteps,
+                ...runBuildCacheSteps
+            ]
+        }),
+        jestTestsNoStorage: createJestTestsJob(null),
+        jestTestsDdb: createJestTestsJob("ddb"),
+        jestTestsDdbEs: createJestTestsJob("ddb-es"),
+        jestTestsDdbOs: createJestTestsJob("ddb-os")
+    }
+});

From 4e4de20ce2a657c5436e3679d97dd2f4ec6202fc Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Wed, 18 Dec 2024 15:37:40 +0100
Subject: [PATCH 19/52] fix(app-website): decorate lexical renderers on first
 mount (#4452)

---
 .../src/layouts/pages/Static/HeaderMobile.tsx |  1 +
 .../src/render/PageBuilder.tsx                | 10 ++--
 .../src/render/lexicalRendererDecorators.tsx  | 10 ++++
 .../elements/heading/LexicalHeading.tsx       | 17 ++++--
 .../elements/paragraph/LexicalParagraph.tsx   | 17 ++++--
 packages/app-website/src/Website.tsx          |  6 +-
 packages/app/src/App.tsx                      | 14 ++++-
 .../src/layouts/pages/Static/HeaderMobile.tsx |  1 +
 packages/react-composition/src/Context.tsx    | 58 +++++++++++++------
 9 files changed, 93 insertions(+), 41 deletions(-)
 create mode 100644 packages/app-page-builder/src/render/lexicalRendererDecorators.tsx

diff --git a/extensions/theme/src/layouts/pages/Static/HeaderMobile.tsx b/extensions/theme/src/layouts/pages/Static/HeaderMobile.tsx
index e31b913cd0d..ad09d180b06 100644
--- a/extensions/theme/src/layouts/pages/Static/HeaderMobile.tsx
+++ b/extensions/theme/src/layouts/pages/Static/HeaderMobile.tsx
@@ -98,6 +98,7 @@ const HeaderMobileWrapper = styled.div`
         }
 
         > nav {
+            display: none;
             -moz-osx-font-smoothing: grayscale;
             -webkit-font-smoothing: antialiased;
             animation: slide-out 0.5s forwards;
diff --git a/packages/app-page-builder/src/render/PageBuilder.tsx b/packages/app-page-builder/src/render/PageBuilder.tsx
index 8dbe63ff019..984ca63afa0 100644
--- a/packages/app-page-builder/src/render/PageBuilder.tsx
+++ b/packages/app-page-builder/src/render/PageBuilder.tsx
@@ -2,17 +2,15 @@ import React from "react";
 import { AddButtonClickHandlers } from "~/elementDecorators/AddButtonClickHandlers";
 import { AddButtonLinkComponent } from "~/elementDecorators/AddButtonLinkComponent";
 import { InjectElementVariables } from "~/render/variables/InjectElementVariables";
-import { LexicalParagraphRenderer } from "~/render/plugins/elements/paragraph/LexicalParagraph";
-import { LexicalHeadingRenderer } from "~/render/plugins/elements/heading/LexicalHeading";
 
-export const PageBuilder = () => {
+export const PageBuilder = React.memo(() => {
     return (
         <>
             <AddButtonLinkComponent />
             <AddButtonClickHandlers />
             <InjectElementVariables />
-            <LexicalParagraphRenderer />
-            <LexicalHeadingRenderer />
         </>
     );
-};
+});
+
+PageBuilder.displayName = "PageBuilder";
diff --git a/packages/app-page-builder/src/render/lexicalRendererDecorators.tsx b/packages/app-page-builder/src/render/lexicalRendererDecorators.tsx
new file mode 100644
index 00000000000..65bc981a453
--- /dev/null
+++ b/packages/app-page-builder/src/render/lexicalRendererDecorators.tsx
@@ -0,0 +1,10 @@
+import type { DecoratorsCollection } from "@webiny/app";
+import { ParagraphRenderer } from "@webiny/app-page-builder-elements/renderers/paragraph";
+import { HeadingRenderer } from "@webiny/app-page-builder-elements/renderers/heading";
+import { LexicalParagraphDecorator } from "~/render/plugins/elements/paragraph/LexicalParagraph";
+import { LexicalHeadingDecorator } from "~/render/plugins/elements/heading/LexicalHeading";
+
+export const lexicalRendererDecorators: DecoratorsCollection = [
+    [ParagraphRenderer.Component, [LexicalParagraphDecorator]],
+    [HeadingRenderer.Component, [LexicalHeadingDecorator]]
+];
diff --git a/packages/app-page-builder/src/render/plugins/elements/heading/LexicalHeading.tsx b/packages/app-page-builder/src/render/plugins/elements/heading/LexicalHeading.tsx
index 284fea7effd..142dbd414a1 100644
--- a/packages/app-page-builder/src/render/plugins/elements/heading/LexicalHeading.tsx
+++ b/packages/app-page-builder/src/render/plugins/elements/heading/LexicalHeading.tsx
@@ -1,14 +1,16 @@
 import React from "react";
 import {
-    HeadingRenderer,
-    elementInputs
+    elementInputs,
+    HeadingRenderer
 } from "@webiny/app-page-builder-elements/renderers/heading";
 import { usePageElements, useRenderer } from "@webiny/app-page-builder-elements";
 import { assignStyles } from "@webiny/app-page-builder-elements/utils";
 import { isValidLexicalData, LexicalHtmlRenderer } from "@webiny/lexical-editor";
+import type { ComponentDecorator } from "@webiny/app";
+import type { Renderer } from "@webiny/app-page-builder-elements/types";
 
-export const LexicalHeadingRenderer = HeadingRenderer.Component.createDecorator(Original => {
-    return function LexicalHeadingRenderer() {
+export const LexicalHeadingDecorator: ComponentDecorator<Renderer> = Original => {
+    return function LexicalHeadingRenderer(props) {
         const { theme } = usePageElements();
         const { getInputValues } = useRenderer();
         const inputs = getInputValues<typeof elementInputs>();
@@ -29,6 +31,9 @@ export const LexicalHeadingRenderer = HeadingRenderer.Component.createDecorator(
             );
         }
 
-        return <Original />;
+        return <Original {...props} />;
     };
-});
+};
+
+export const LexicalHeadingRenderer =
+    HeadingRenderer.Component.createDecorator(LexicalHeadingDecorator);
diff --git a/packages/app-page-builder/src/render/plugins/elements/paragraph/LexicalParagraph.tsx b/packages/app-page-builder/src/render/plugins/elements/paragraph/LexicalParagraph.tsx
index fb6bce057d9..357fecc0132 100644
--- a/packages/app-page-builder/src/render/plugins/elements/paragraph/LexicalParagraph.tsx
+++ b/packages/app-page-builder/src/render/plugins/elements/paragraph/LexicalParagraph.tsx
@@ -1,14 +1,16 @@
 import React from "react";
 import {
-    ParagraphRenderer,
-    elementInputs
+    elementInputs,
+    ParagraphRenderer
 } from "@webiny/app-page-builder-elements/renderers/paragraph";
 import { usePageElements, useRenderer } from "@webiny/app-page-builder-elements";
 import { assignStyles } from "@webiny/app-page-builder-elements/utils";
 import { isValidLexicalData, LexicalHtmlRenderer } from "@webiny/lexical-editor";
+import type { ComponentDecorator } from "@webiny/app";
+import type { Renderer } from "@webiny/app-page-builder-elements/types";
 
-export const LexicalParagraphRenderer = ParagraphRenderer.Component.createDecorator(Original => {
-    return function LexicalParagraphRenderer() {
+export const LexicalParagraphDecorator: ComponentDecorator<Renderer> = Original => {
+    return function LexicalParagraphRenderer(props) {
         const { theme } = usePageElements();
         const { getInputValues } = useRenderer();
         const inputs = getInputValues<typeof elementInputs>();
@@ -29,6 +31,9 @@ export const LexicalParagraphRenderer = ParagraphRenderer.Component.createDecora
             );
         }
 
-        return <Original />;
+        return <Original {...props} />;
     };
-});
+};
+
+export const LexicalParagraphRenderer =
+    ParagraphRenderer.Component.createDecorator(LexicalParagraphDecorator);
diff --git a/packages/app-website/src/Website.tsx b/packages/app-website/src/Website.tsx
index 229798fdfff..27986a23d4a 100644
--- a/packages/app-website/src/Website.tsx
+++ b/packages/app-website/src/Website.tsx
@@ -2,12 +2,13 @@ import React, { useMemo } from "react";
 import { App, AppProps, Decorator, GenericComponent } from "@webiny/app";
 import { ApolloProvider } from "@apollo/react-hooks";
 import { CacheProvider } from "@emotion/react";
-import { Page } from "./Page";
-import { createApolloClient, createEmotionCache } from "~/utils";
 import { ThemeProvider } from "@webiny/app-theme";
 import { PageBuilderProvider } from "@webiny/app-page-builder/contexts/PageBuilder";
+import { lexicalRendererDecorators } from "@webiny/app-page-builder/render/lexicalRendererDecorators";
 import { PageBuilder } from "@webiny/app-page-builder/render";
 import { RouteProps } from "@webiny/react-router";
+import { createApolloClient, createEmotionCache } from "~/utils";
+import { Page } from "./Page";
 import { LinkPreload } from "~/LinkPreload";
 import { WebsiteLoaderCache } from "~/utils/WebsiteLoaderCache";
 
@@ -53,6 +54,7 @@ export const Website = ({ children, routes = [], providers = [], ...props }: Web
                         debounceRender={debounceMs}
                         routes={appRoutes}
                         providers={[PageBuilderProviderHOC, ...providers]}
+                        decorators={[...lexicalRendererDecorators]}
                     >
                         <LinkPreload />
                         <PageBuilder />
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index 2f88dff6229..c4f4f5d5ca5 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -13,7 +13,8 @@ import {
     GenericComponent,
     compose,
     Decorator,
-    HigherOrderComponent
+    HigherOrderComponent,
+    DecoratorsCollection
 } from "@webiny/react-composition";
 import { Routes as SortRoutes } from "./core/Routes";
 import { DebounceRender } from "./core/DebounceRender";
@@ -53,6 +54,7 @@ export interface AppProps {
     debounceRender?: number;
     routes?: Array<RouteProps>;
     providers?: Array<Decorator<GenericComponent<ProviderProps>>>;
+    decorators?: DecoratorsCollection;
     children?: React.ReactNode | React.ReactNode[];
 }
 
@@ -62,7 +64,13 @@ interface ProviderProps {
 
 type ComponentWithChildren = React.ComponentType<{ children?: React.ReactNode }>;
 
-export const App = ({ debounceRender = 50, routes = [], providers = [], children }: AppProps) => {
+export const App = ({
+    debounceRender = 50,
+    routes = [],
+    providers = [],
+    decorators = [],
+    children
+}: AppProps) => {
     const [state, setState] = useState<State>({
         routes: routes.reduce<RoutesByPath>((acc, item) => {
             return { ...acc, [item.path as string]: <Route {...item} /> };
@@ -129,7 +137,7 @@ export const App = ({ debounceRender = 50, routes = [], providers = [], children
 
     return (
         <AppContext.Provider value={appContext}>
-            <CompositionProvider>
+            <CompositionProvider decorators={decorators}>
                 {children}
                 <BrowserRouter>
                     <Providers>
diff --git a/packages/cwp-template-aws/template/common/extensions/theme/src/layouts/pages/Static/HeaderMobile.tsx b/packages/cwp-template-aws/template/common/extensions/theme/src/layouts/pages/Static/HeaderMobile.tsx
index c254e2cb9aa..990974bf370 100644
--- a/packages/cwp-template-aws/template/common/extensions/theme/src/layouts/pages/Static/HeaderMobile.tsx
+++ b/packages/cwp-template-aws/template/common/extensions/theme/src/layouts/pages/Static/HeaderMobile.tsx
@@ -98,6 +98,7 @@ const HeaderMobileWrapper = styled.div`
         }
 
         > nav {
+            display: none;
             -moz-osx-font-smoothing: grayscale;
             -webkit-font-smoothing: antialiased;
             animation: slide-out 0.5s forwards;
diff --git a/packages/react-composition/src/Context.tsx b/packages/react-composition/src/Context.tsx
index 27b7fffd4ea..f7c08166a48 100644
--- a/packages/react-composition/src/Context.tsx
+++ b/packages/react-composition/src/Context.tsx
@@ -10,6 +10,7 @@ import { useCompositionScope } from "~/CompositionScope";
 import {
     ComposedFunction,
     ComposeWith,
+    Decoratable,
     DecoratableComponent,
     DecoratableHook,
     Decorator,
@@ -70,35 +71,56 @@ interface CompositionContext {
 
 const CompositionContext = createContext<CompositionContext | undefined>(undefined);
 
+export type DecoratorsTuple = [Decoratable, Decorator<any>[]];
+export type DecoratorsCollection = Array<DecoratorsTuple>;
+
 interface CompositionProviderProps {
+    decorators?: DecoratorsCollection;
     children: React.ReactNode;
 }
 
-export const CompositionProvider = ({ children }: CompositionProviderProps) => {
-    const [components, setComponents] = useState<ComponentScopes>(new Map());
+const composeComponents = (
+    components: ComponentScopes,
+    decorators: Array<[GenericComponent | GenericHook, Decorator<any>[]]>,
+    scope = "*"
+) => {
+    const scopeMap: ComposedComponents = components.get(scope) || new Map();
+    for (const [component, hocs] of decorators) {
+        const recipe = scopeMap.get(component) || { component: null, hocs: [] };
+
+        const newHocs = [...(recipe.hocs || []), ...hocs] as Decorator<
+            GenericHook | GenericComponent
+        >[];
+
+        scopeMap.set(component, {
+            component: compose(...[...newHocs].reverse())(component),
+            hocs: newHocs
+        });
+
+        components.set(scope, scopeMap);
+    }
+
+    return components;
+};
+
+export const CompositionProvider = ({ decorators = [], children }: CompositionProviderProps) => {
+    const [components, setComponents] = useState<ComponentScopes>(() => {
+        return composeComponents(
+            new Map(),
+            decorators.map(tuple => {
+                return [tuple[0].original, tuple[1]];
+            })
+        );
+    });
 
     const composeComponent = useCallback(
         (
-            component: GenericHook | GenericComponent,
+            component: GenericComponent | GenericHook,
             hocs: HigherOrderComponent<any, any>[],
             scope: string | undefined = "*"
         ) => {
             setComponents(prevComponents => {
-                const components = new Map(prevComponents);
-                const scopeMap: ComposedComponents = components.get(scope) || new Map();
-                const recipe = scopeMap.get(component) || { component: null, hocs: [] };
-
-                const newHocs = [...(recipe.hocs || []), ...hocs] as Decorator<
-                    GenericHook | GenericComponent
-                >[];
-
-                scopeMap.set(component, {
-                    component: compose(...[...newHocs].reverse())(component),
-                    hocs: newHocs
-                });
-
-                components.set(scope, scopeMap);
-                return components;
+                return composeComponents(new Map(prevComponents), [[component, hocs]], scope);
             });
 
             // Return a function that will remove the added HOCs.

From 0d891cd4ac9f5c3c5922210b0132c03c48be722b Mon Sep 17 00:00:00 2001
From: adrians5j <adrian@webiny.com>
Date: Wed, 18 Dec 2024 16:41:27 +0100
Subject: [PATCH 20/52] fix: filter out non-version-like names

---
 .../cli-plugin-extensions/src/downloadAndLinkExtension.ts  | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/packages/cli-plugin-extensions/src/downloadAndLinkExtension.ts b/packages/cli-plugin-extensions/src/downloadAndLinkExtension.ts
index 923e12da8f3..571c0417ee6 100644
--- a/packages/cli-plugin-extensions/src/downloadAndLinkExtension.ts
+++ b/packages/cli-plugin-extensions/src/downloadAndLinkExtension.ts
@@ -15,15 +15,18 @@ import { CliContext } from "@webiny/cli/types";
 import { Ora } from "ora";
 
 const EXTENSIONS_ROOT_FOLDER = "extensions";
-
 const S3_BUCKET_NAME = "webiny-examples";
 const S3_BUCKET_REGION = "us-east-1";
+const FOLDER_NAME_IS_VERSION_REGEX = /^\d+\.\d+\.x$/;
 
 const getVersionFromVersionFolders = async (
     versionFoldersList: string[],
     currentWebinyVersion: string
 ) => {
-    const availableVersions = versionFoldersList.map(v => v.replace(".x", ".0")).sort();
+    const availableVersions = versionFoldersList
+        .filter(v => v.match(FOLDER_NAME_IS_VERSION_REGEX))
+        .map(v => v.replace(".x", ".0"))
+        .sort();
 
     let versionToUse = "";
 

From f0224a26a3fde88b3590547622f938a2b03ba191 Mon Sep 17 00:00:00 2001
From: Adrian Smijulj <adrian1358@gmail.com>
Date: Thu, 19 Dec 2024 15:56:14 +0100
Subject: [PATCH 21/52] fix: introduce `CmsContentFormRendererPlugin`  (#4456)

---
 packages/app-headless-cms/src/index.tsx             |  2 ++
 .../src/plugins/CmsContentFormRendererPlugin.ts     | 13 +++++++++++++
 packages/app-headless-cms/src/plugins/index.ts      |  1 +
 3 files changed, 16 insertions(+)
 create mode 100644 packages/app-headless-cms/src/plugins/CmsContentFormRendererPlugin.ts
 create mode 100644 packages/app-headless-cms/src/plugins/index.ts

diff --git a/packages/app-headless-cms/src/index.tsx b/packages/app-headless-cms/src/index.tsx
index f2c7899b21e..4207ac6cd29 100644
--- a/packages/app-headless-cms/src/index.tsx
+++ b/packages/app-headless-cms/src/index.tsx
@@ -36,3 +36,5 @@ export const ContentEntriesViewConfig = Object.assign(LegacyContentEntriesViewCo
 });
 
 export { ContentEntryEditorConfig } from "./ContentEntryEditorConfig";
+
+export * from "./plugins";
diff --git a/packages/app-headless-cms/src/plugins/CmsContentFormRendererPlugin.ts b/packages/app-headless-cms/src/plugins/CmsContentFormRendererPlugin.ts
new file mode 100644
index 00000000000..551452b04ae
--- /dev/null
+++ b/packages/app-headless-cms/src/plugins/CmsContentFormRendererPlugin.ts
@@ -0,0 +1,13 @@
+import type { CmsContentFormRendererPlugin as BaseCmsContentFormRendererPlugin } from "~/types";
+import { legacyPluginToReactComponent } from "@webiny/app/utils";
+
+export type CmsContentFormRendererPluginProps = Pick<
+    BaseCmsContentFormRendererPlugin,
+    "modelId" | "render"
+>;
+
+export const CmsContentFormRendererPlugin =
+    legacyPluginToReactComponent<CmsContentFormRendererPluginProps>({
+        pluginType: "cms-content-form-renderer",
+        componentDisplayName: "CmsContentFormRendererPlugin"
+    });
diff --git a/packages/app-headless-cms/src/plugins/index.ts b/packages/app-headless-cms/src/plugins/index.ts
new file mode 100644
index 00000000000..e6993879cb2
--- /dev/null
+++ b/packages/app-headless-cms/src/plugins/index.ts
@@ -0,0 +1 @@
+export * from "./CmsContentFormRendererPlugin";

From 4ee6137c2419d4f955b320f470c7ca3ec1a7e40e Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Thu, 19 Dec 2024 16:02:20 +0100
Subject: [PATCH 22/52] fix(lexical-editor): make font sizes configurable
 through theme (#4454)

---
 .../LexicalPresets/HeadingEditorPreset.tsx    | 13 +++-
 .../LexicalPresets/ParagraphEditorPreset.tsx  | 14 ++++-
 .../src/components/Toolbar/Toolbar.css        |  4 --
 .../ToolbarActions/FontSizeAction.tsx         | 63 ++++++++++++++-----
 packages/theme/src/types.ts                   | 14 ++++-
 5 files changed, 82 insertions(+), 26 deletions(-)

diff --git a/packages/lexical-editor-pb-element/src/components/LexicalPresets/HeadingEditorPreset.tsx b/packages/lexical-editor-pb-element/src/components/LexicalPresets/HeadingEditorPreset.tsx
index 82525b9aa88..f35b6770f2e 100644
--- a/packages/lexical-editor-pb-element/src/components/LexicalPresets/HeadingEditorPreset.tsx
+++ b/packages/lexical-editor-pb-element/src/components/LexicalPresets/HeadingEditorPreset.tsx
@@ -15,15 +15,24 @@ import {
     TypographyPlugin,
     UnderlineAction,
     LexicalEditorConfig,
-    LinkPlugin
+    LinkPlugin,
+    useRichTextEditor
 } from "@webiny/lexical-editor";
 
 const { ToolbarElement, Plugin } = LexicalEditorConfig;
 
+const FontSizeActionWithTheme = () => {
+    const { theme } = useRichTextEditor();
+
+    const fontSizes = theme?.styles?.fontSizes?.heading ?? FontSizeAction.FONT_SIZES_FALLBACK;
+
+    return <FontSizeAction fontSizes={fontSizes} />;
+};
+
 export const HeadingEditorPreset = () => {
     return (
         <LexicalEditorConfig>
-            <ToolbarElement name="fontSize" element={<FontSizeAction />} />
+            <ToolbarElement name="fontSize" element={<FontSizeActionWithTheme />} />
             <ToolbarElement name="fontColor" element={<FontColorAction />} />
             <ToolbarElement name="typography" element={<TypographyAction />} />
             <ToolbarElement name="textAlignment" element={<TextAlignmentAction />} />
diff --git a/packages/lexical-editor-pb-element/src/components/LexicalPresets/ParagraphEditorPreset.tsx b/packages/lexical-editor-pb-element/src/components/LexicalPresets/ParagraphEditorPreset.tsx
index 5f2e109963c..1ab46d12175 100644
--- a/packages/lexical-editor-pb-element/src/components/LexicalPresets/ParagraphEditorPreset.tsx
+++ b/packages/lexical-editor-pb-element/src/components/LexicalPresets/ParagraphEditorPreset.tsx
@@ -20,14 +20,24 @@ import {
     UnderlineAction,
     ListPlugin,
     LexicalEditorConfig,
-    LinkPlugin
+    LinkPlugin,
+    useRichTextEditor
 } from "@webiny/lexical-editor";
 
 const { ToolbarElement, Plugin } = LexicalEditorConfig;
+
+const FontSizeActionWithTheme = () => {
+    const { theme } = useRichTextEditor();
+
+    const fontSizes = theme?.styles?.fontSizes?.paragraph ?? FontSizeAction.FONT_SIZES_FALLBACK;
+
+    return <FontSizeAction fontSizes={fontSizes} />;
+};
+
 export const ParagraphEditorPreset = () => {
     return (
         <LexicalEditorConfig>
-            <ToolbarElement name="fontSize" element={<FontSizeAction />} />
+            <ToolbarElement name="fontSize" element={<FontSizeActionWithTheme />} />
             <ToolbarElement name="fontColor" element={<FontColorAction />} />
             <ToolbarElement name="typography" element={<TypographyAction />} />
             <ToolbarElement name="textAlignment" element={<TextAlignmentAction />} />
diff --git a/packages/lexical-editor/src/components/Toolbar/Toolbar.css b/packages/lexical-editor/src/components/Toolbar/Toolbar.css
index 8bc676633ab..93ce67cea77 100644
--- a/packages/lexical-editor/src/components/Toolbar/Toolbar.css
+++ b/packages/lexical-editor/src/components/Toolbar/Toolbar.css
@@ -326,10 +326,6 @@ i.font-color,
   margin: 0 4px;
 }
 
-.toolbar-item.font-size {
-  width: 70px;
-}
-
 .lexical-dropdown-container {
   position: absolute;
   bottom: -5px;
diff --git a/packages/lexical-editor/src/components/ToolbarActions/FontSizeAction.tsx b/packages/lexical-editor/src/components/ToolbarActions/FontSizeAction.tsx
index 4051dbf63eb..8d3eaee1059 100644
--- a/packages/lexical-editor/src/components/ToolbarActions/FontSizeAction.tsx
+++ b/packages/lexical-editor/src/components/ToolbarActions/FontSizeAction.tsx
@@ -6,7 +6,13 @@ import { DropDown, DropDownItem } from "~/ui/DropDown";
 import { useDeriveValueFromSelection } from "~/hooks/useCurrentSelection";
 import { useRichTextEditor } from "~/hooks";
 
-const FONT_SIZE_OPTIONS: string[] = [
+export interface FontSize {
+    id: string;
+    name: string;
+    value: string;
+}
+
+export const FONT_SIZES_FALLBACK: FontSize[] = [
     "8px",
     "9px",
     "10px",
@@ -24,7 +30,18 @@ const FONT_SIZE_OPTIONS: string[] = [
     "60px",
     "72px",
     "96px"
-];
+].map(size => ({
+    id: size,
+    name: size,
+    value: size,
+    default: size === "15px"
+}));
+
+const emptyOption: FontSize = {
+    value: "",
+    name: "Font Size",
+    id: "empty"
+};
 
 function dropDownActiveClass(active: boolean) {
     if (active) {
@@ -34,21 +51,22 @@ function dropDownActiveClass(active: boolean) {
 }
 
 interface FontSizeDropDownProps {
+    fontSizes: FontSize[];
     editor: LexicalEditor;
-    value: string;
+    value: string | undefined;
     disabled?: boolean;
 }
 
 function FontSizeDropDown(props: FontSizeDropDownProps): JSX.Element {
-    const { editor, value, disabled = false } = props;
+    const { editor, value, fontSizes, disabled = false } = props;
 
     const handleClick = useCallback(
-        (option: string) => {
+        (option: FontSize) => {
             editor.update(() => {
                 const selection = $getSelection();
                 if ($isRangeSelection(selection)) {
                     $patchStyleText(selection, {
-                        ["font-size"]: option
+                        ["font-size"]: option.value
                     });
                 }
             });
@@ -56,39 +74,43 @@ function FontSizeDropDown(props: FontSizeDropDownProps): JSX.Element {
         [editor]
     );
 
+    const selectedOption = fontSizes.find(opt => opt.value === value) ?? emptyOption;
+
     return (
         <DropDown
             disabled={disabled}
             buttonClassName="toolbar-item font-size"
-            buttonLabel={value}
+            buttonLabel={selectedOption.name}
             buttonAriaLabel={"Formatting options for font size"}
         >
-            {FONT_SIZE_OPTIONS.map(option => (
+            {fontSizes.map(option => (
                 <DropDownItem
-                    className={`item fontsize-item ${dropDownActiveClass(value === option)}`}
+                    className={`item fontsize-item ${dropDownActiveClass(value === option.id)}`}
                     onClick={() => handleClick(option)}
-                    key={option}
+                    key={option.id}
                 >
-                    <span className="text">{option}</span>
+                    <span className="text">{option.name}</span>
                 </DropDownItem>
             ))}
         </DropDown>
     );
 }
 
-const defaultSize = "15px";
+interface FontSizeActionProps {
+    fontSizes?: FontSize[];
+}
 
-export const FontSizeAction = () => {
+const FontSize = ({ fontSizes = FONT_SIZES_FALLBACK }: FontSizeActionProps) => {
     const { editor } = useRichTextEditor();
     const [isEditable, setIsEditable] = useState(() => editor.isEditable());
     const fontSize = useDeriveValueFromSelection(({ rangeSelection }) => {
         if (!rangeSelection) {
-            return defaultSize;
+            return undefined;
         }
         try {
-            return $getSelectionStyleValueForProperty(rangeSelection, "font-size", "15px");
+            return $getSelectionStyleValueForProperty(rangeSelection, "font-size");
         } catch {
-            return defaultSize;
+            return undefined;
         }
     });
 
@@ -102,7 +124,14 @@ export const FontSizeAction = () => {
 
     return (
         <>
-            <FontSizeDropDown disabled={!isEditable} value={fontSize} editor={editor} />
+            <FontSizeDropDown
+                disabled={!isEditable}
+                value={fontSize}
+                editor={editor}
+                fontSizes={fontSizes}
+            />
         </>
     );
 };
+
+export const FontSizeAction = Object.assign(FontSize, { FONT_SIZES_FALLBACK });
diff --git a/packages/theme/src/types.ts b/packages/theme/src/types.ts
index 0c110ea9396..a5b3539b3a7 100644
--- a/packages/theme/src/types.ts
+++ b/packages/theme/src/types.ts
@@ -27,8 +27,10 @@ export type ThemeBreakpoints = {
 
 /*
  * Typography section
+ * We want to allow custom strings as well, thus the (string & {}).
  */
-export type TypographyType = "headings" | "paragraphs" | "quotes" | "lists" | string;
+// eslint-disable-next-line @typescript-eslint/ban-types
+export type TypographyType = "headings" | "paragraphs" | "quotes" | "lists" | (string & {});
 export type TypographyStyle = {
     id: string;
     name: string;
@@ -39,8 +41,18 @@ export type TypographyStyle = {
 export type Typography = Record<TypographyType, Readonly<TypographyStyle[]>>;
 export type ThemeTypographyStyleItems = TypographyStyle[];
 
+export interface FontSize {
+    id: string;
+    name: string;
+    value: string;
+}
+
 export interface ThemeStyles {
     colors: Record<string, any>;
+    fontSizes?: {
+        heading?: FontSize[];
+        paragraph?: FontSize[];
+    };
     borderRadius?: number;
     typography: Typography;
     elements: Record<string, Record<string, any> | StylesObject>;

From 485f8701488b4528400fe2df0d90c10b8f3cde89 Mon Sep 17 00:00:00 2001
From: Adrian Smijulj <adrian1358@gmail.com>
Date: Thu, 19 Dec 2024 16:20:03 +0100
Subject: [PATCH 23/52] fix: improve Webiny CLI developer-experience  (#4455)

---
 .../commands/newWatch.js                      | 14 +++++++----
 .../commands/newWatch/listPackages.js         |  6 +++++
 .../src/downloadAndLinkExtension.ts           |  8 +++++++
 .../src/extensions/AdminExtension.ts          |  2 +-
 .../src/extensions/ApiExtension.ts            |  2 +-
 .../src/extensions/PbElementExtension.ts      |  2 +-
 .../src/generateExtension.ts                  |  8 +++++++
 .../bundling/function/watchFunction.js        | 23 +++++++++++++++----
 8 files changed, 52 insertions(+), 13 deletions(-)

diff --git a/packages/cli-plugin-deploy-pulumi/commands/newWatch.js b/packages/cli-plugin-deploy-pulumi/commands/newWatch.js
index 9230f92ba60..5fe5a1044ed 100644
--- a/packages/cli-plugin-deploy-pulumi/commands/newWatch.js
+++ b/packages/cli-plugin-deploy-pulumi/commands/newWatch.js
@@ -121,8 +121,10 @@ module.exports = async (inputs, context) => {
     const learnMoreLink = "https://webiny.link/local-aws-lambda-development";
 
     context.info(`Local AWS Lambda development session started.`);
-    context.info(
-        `Note that you should deploy your changes once you're done. To do so, run: %s. Learn more: %s.`,
+    context.warning(
+        `Note that once the session is terminated, the %s application will no longer work. To fix this, you %s redeploy it via the %s command. Learn more: %s.`,
+        projectApplication.name,
+        "MUST",
         deployCommand,
         learnMoreLink
     );
@@ -141,9 +143,11 @@ module.exports = async (inputs, context) => {
         console.log();
         console.log();
 
-        context.info(`Stopping local AWS Lambda development session.`);
-        context.info(
-            `Note that you should deploy your changes. To do so, run: %s. Learn more: %s.`,
+        context.info(`Terminating local AWS Lambda development session.`);
+        context.warning(
+            `Note that once the session is terminated, the %s application will no longer work. To fix this, you %s redeploy it via the %s command. Learn more: %s.`,
+            projectApplication.name,
+            "MUST",
             deployCommand,
             learnMoreLink
         );
diff --git a/packages/cli-plugin-deploy-pulumi/commands/newWatch/listPackages.js b/packages/cli-plugin-deploy-pulumi/commands/newWatch/listPackages.js
index 8722bb6035e..f4c6117e7bf 100644
--- a/packages/cli-plugin-deploy-pulumi/commands/newWatch/listPackages.js
+++ b/packages/cli-plugin-deploy-pulumi/commands/newWatch/listPackages.js
@@ -56,6 +56,12 @@ const listPackages = async ({ inputs }) => {
                 ? path.join(root, "webiny.config.ts")
                 : path.join(root, "webiny.config.js");
 
+            // We need this because newly introduced extension
+            // packages do not have a Webiny config file.
+            if (!fs.existsSync(configPath)) {
+                continue;
+            }
+
             packages.push({
                 name: packageName,
                 config: require(configPath).default || require(configPath),
diff --git a/packages/cli-plugin-extensions/src/downloadAndLinkExtension.ts b/packages/cli-plugin-extensions/src/downloadAndLinkExtension.ts
index 571c0417ee6..a32e2f4a1ac 100644
--- a/packages/cli-plugin-extensions/src/downloadAndLinkExtension.ts
+++ b/packages/cli-plugin-extensions/src/downloadAndLinkExtension.ts
@@ -159,6 +159,14 @@ export const downloadAndLinkExtension = async ({
                 console.log(`  ‣ ${context.success.hl(p)}`);
             });
         }
+
+        console.log();
+        console.log(chalk.bold("Additional Notes"));
+        console.log(
+            `‣ note that if you already have the ${context.success.hl(
+                "webiny watch"
+            )} command running, you'll need to restart it`
+        );
     } catch (e) {
         switch (e.code) {
             case "NO_OBJECTS_FOUND":
diff --git a/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts b/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts
index 87575cfb6f7..bfb4caa770a 100644
--- a/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts
+++ b/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts
@@ -30,7 +30,7 @@ export class AdminExtension extends AbstractExtension {
         const indexTsxFilePath = `${extensionsFolderPath}/src/index.tsx`;
 
         return [
-            `run ${chalk.green(watchCommand)} to start a new local development session`,
+            `run ${chalk.green(watchCommand)} to start local development`,
             `open ${chalk.green(indexTsxFilePath)} and start coding`,
             `to install additional dependencies, run ${chalk.green(
                 `yarn workspace ${this.params.packageName} add <package-name>`
diff --git a/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts b/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts
index 35e6c269235..8cc85806e4a 100644
--- a/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts
+++ b/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts
@@ -30,7 +30,7 @@ export class ApiExtension extends AbstractExtension {
         const indexTsxFilePath = `${extensionsFolderPath}/src/index.ts`;
 
         return [
-            `run ${chalk.green(watchCommand)} to start a new local development session`,
+            `run ${chalk.green(watchCommand)} to start local development`,
             `open ${chalk.green(indexTsxFilePath)} and start coding`,
             `to install additional dependencies, run ${chalk.green(
                 `yarn workspace ${this.params.packageName} add <package-name>`
diff --git a/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts b/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts
index 0f7be7d90ce..c49767a149c 100644
--- a/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts
+++ b/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts
@@ -37,7 +37,7 @@ export class PbElementExtension extends AbstractExtension {
 
         return [
             [
-                `run the following commands to start local development sessions:`,
+                `run the following commands to start local development:`,
                 `  ∙ ${chalk.green(watchCommandAdmin)}`,
                 `  ∙ ${chalk.green(watchCommandWebsite)}`
             ].join("\n"),
diff --git a/packages/cli-plugin-extensions/src/generateExtension.ts b/packages/cli-plugin-extensions/src/generateExtension.ts
index b405ac5c265..55ece0fe8a3 100644
--- a/packages/cli-plugin-extensions/src/generateExtension.ts
+++ b/packages/cli-plugin-extensions/src/generateExtension.ts
@@ -158,6 +158,14 @@ export const generateExtension = async ({ input, ora, context }: GenerateExtensi
                 console.log(`‣ ${message}`);
             });
         }
+
+        console.log();
+        console.log(chalk.bold("Additional Notes"));
+        console.log(
+            `‣ note that if you already have the ${context.success.hl(
+                "webiny watch"
+            )} command running, you'll need to restart it`
+        );
     } catch (err) {
         ora.fail("Could not create extension. Please check the logs below.");
         console.log();
diff --git a/packages/project-utils/bundling/function/watchFunction.js b/packages/project-utils/bundling/function/watchFunction.js
index 22fccbf6f45..0f909ee6907 100644
--- a/packages/project-utils/bundling/function/watchFunction.js
+++ b/packages/project-utils/bundling/function/watchFunction.js
@@ -31,16 +31,29 @@ module.exports = async options => {
     }
 
     return new Promise(async (resolve, reject) => {
-        options.logs && console.log("Compiling...");
+        let initialCompilation = true;
+        if (options.logs) {
+            const message = initialCompilation ? "Initial compilation started..." : "Compiling...";
+            console.log(message);
+        }
+
         return webpack(webpackConfig).watch({}, async (err, stats) => {
             if (err) {
                 return reject(err);
             }
 
-            if (!stats.hasErrors()) {
-                options.logs && console.log("Compiled successfully.");
-            } else {
-                options.logs && console.log(stats.toString("errors-warnings"));
+            if (!options.logs) {
+                return;
+            }
+
+            if (stats.hasErrors()) {
+                console.log(stats.toString("errors-warnings"));
+                return;
+            }
+
+            if (initialCompilation) {
+                initialCompilation = false;
+                console.log("Initial compilation completed. Watching for changes...");
             }
         });
     });

From 46a11e9aa15d5c5a7561b98008f8115c0dd410c0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bruno=20Zori=C4=87?= <bruno.zoric@gmail.com>
Date: Thu, 19 Dec 2024 14:56:28 +0100
Subject: [PATCH 24/52] fix(api-form-builder): skip create fb on new locale if
 fb not installed on current locale

---
 .../src/operations/form/index.ts                       |  3 ++-
 packages/api-form-builder/__tests__/forms.test.ts      | 10 +++++-----
 packages/api-form-builder/src/plugins/crud/index.ts    |  6 ++++++
 3 files changed, 13 insertions(+), 6 deletions(-)

diff --git a/packages/api-form-builder-so-ddb/src/operations/form/index.ts b/packages/api-form-builder-so-ddb/src/operations/form/index.ts
index c25f60780dc..8177b0c942a 100644
--- a/packages/api-form-builder-so-ddb/src/operations/form/index.ts
+++ b/packages/api-form-builder-so-ddb/src/operations/form/index.ts
@@ -513,7 +513,8 @@ export const createFormStorageOperations = (
         }
         let latestPublishedKeys: Keys | undefined;
         const entityBatch = createEntityWriteBatch({
-            entity
+            entity,
+            delete: [createLatestKeys(form)]
         });
 
         for (const item of items) {
diff --git a/packages/api-form-builder/__tests__/forms.test.ts b/packages/api-form-builder/__tests__/forms.test.ts
index 0b29a520ec1..a2b9fc330b0 100644
--- a/packages/api-form-builder/__tests__/forms.test.ts
+++ b/packages/api-form-builder/__tests__/forms.test.ts
@@ -40,7 +40,7 @@ describe('Form Builder "Form" Test', () => {
         }
     });
 
-    test("should create a form and return it in the list of latest forms", async () => {
+    it("should create a form and return it in the list of latest forms", async () => {
         const [create] = await createForm({ data: { name: "contact-us" } });
         const { id } = create.data.formBuilder.createForm.data;
 
@@ -70,7 +70,7 @@ describe('Form Builder "Form" Test', () => {
         expect(data[0].id).toEqual(id);
     });
 
-    test("should update form and return new data from storage", async () => {
+    it("should update form and return new data from storage", async () => {
         const [create] = await createForm({ data: { name: "contact-us" } });
         const { id } = create.data.formBuilder.createForm.data;
 
@@ -219,7 +219,7 @@ describe('Form Builder "Form" Test', () => {
         expect(revisions[0].version).toEqual(2);
     });
 
-    test("should delete a form and all of its revisions", async () => {
+    it("should delete a form and all of its revisions", async () => {
         const [create] = await createForm({ data: { name: "contact-us" } });
         const { id } = create.data.formBuilder.createForm.data;
 
@@ -246,7 +246,7 @@ describe('Form Builder "Form" Test', () => {
         expect(list.data.formBuilder.listForms.data.length).toBe(0);
     });
 
-    test("should publish, add views and unpublish", async () => {
+    it("should publish, add views and unpublish", async () => {
         const [create] = await createForm({ data: { name: "contact-us" } });
         const { id } = create.data.formBuilder.createForm.data;
 
@@ -306,7 +306,7 @@ describe('Form Builder "Form" Test', () => {
         expect(latestPublished3.data.formBuilder.getPublishedForm.data.id).toEqual(id);
     });
 
-    test("should create, list and export submissions to file", async () => {
+    it("should create, list and export submissions to file", async () => {
         const [create] = await createForm({ data: { name: "contact-us" } });
         const { id } = create.data.formBuilder.createForm.data;
 
diff --git a/packages/api-form-builder/src/plugins/crud/index.ts b/packages/api-form-builder/src/plugins/crud/index.ts
index 5a20f2300b6..0c94f6f367a 100644
--- a/packages/api-form-builder/src/plugins/crud/index.ts
+++ b/packages/api-form-builder/src/plugins/crud/index.ts
@@ -111,6 +111,12 @@ export default (params: CreateFormBuilderCrudParams) => {
         // Once a new locale is created, we need to create a new settings entry for it.
         new ContextPlugin<FormBuilderContext>(async context => {
             context.i18n.locales.onLocaleAfterCreate.subscribe(async params => {
+                // We don't want to auto-create the settings entry if Form Builder is not installed.
+                // This is because the entry will be created by the app's installer.
+                const fbIsInstalled = Boolean(await context.formBuilder.getSystemVersion());
+                if (!fbIsInstalled) {
+                    return;
+                }
                 const { locale } = params;
                 await context.i18n.withLocale(locale, async () => {
                     return context.formBuilder.createSettings({});

From 1b718530041b47e31fa19eaa35cee8ff96736e7a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bruno=20Zori=C4=87?= <bruno.zoric@gmail.com>
Date: Thu, 19 Dec 2024 15:29:45 +0100
Subject: [PATCH 25/52] chore: ghawac build

---
 .github/workflows/pullRequests.yml            | 32 ++++++++---------
 .../workflows/pullRequestsCommandCypress.yml  | 36 +++++++++----------
 .github/workflows/pullRequestsCommandJest.yml | 26 +++++++-------
 3 files changed, 47 insertions(+), 47 deletions(-)

diff --git a/.github/workflows/pullRequests.yml b/.github/workflows/pullRequests.yml
index 07a21d893db..07bef462152 100644
--- a/.github/workflows/pullRequests.yml
+++ b/.github/workflows/pullRequests.yml
@@ -3,7 +3,7 @@
 # and run "github-actions-wac build" (or "ghawac build") to regenerate this file.
 # For more information, run "github-actions-wac --help".
 name: Pull Requests
-"on": pull_request
+'on': pull_request
 concurrency:
   group: pr-${{ github.event.pull_request.number }}
   cancel-in-progress: true
@@ -19,7 +19,7 @@ jobs:
       - uses: webiny/action-conventional-commits@v1.3.0
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   validateCommitsDev:
     name: Validate commit messages (dev branch, 'feat' commits not allowed)
@@ -34,7 +34,7 @@ jobs:
           allowed-commit-types: fix,docs,style,refactor,test,build,perf,ci,chore,revert,merge,wip
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   constants:
     name: Create constants
@@ -87,7 +87,7 @@ jobs:
           $GITHUB_OUTPUT
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   assignMilestone:
     name: Assign milestone
@@ -117,7 +117,7 @@ jobs:
           milestone: ${{ steps.get-milestone-to-assign.outputs.milestone }}
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   build:
     name: Build
@@ -149,7 +149,7 @@ jobs:
           path: ${{ github.base_ref }}/.webiny/cached-packages
           key: ${{ needs.constants.outputs.run-cache-key }}
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   staticCodeAnalysis:
     needs:
@@ -187,7 +187,7 @@ jobs:
         working-directory: ${{ github.base_ref }}
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   staticCodeAnalysisTs:
     name: Static code analysis (TypeScript)
@@ -213,7 +213,7 @@ jobs:
         run: yarn cy:ts
         working-directory: ${{ github.base_ref }}
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   jestTestsNoStorageConstants:
     needs:
@@ -241,7 +241,7 @@ jobs:
           echo '${{
           steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}'
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   jestTestsNoStorageRun:
     needs:
@@ -262,7 +262,7 @@ jobs:
           }}
     runs-on: ${{ matrix.os }}
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
       AWS_REGION: eu-central-1
     if: needs.jestTestsNoStorageConstants.outputs.packages-to-jest-test != '[]'
@@ -359,7 +359,7 @@ jobs:
           echo '${{
           steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}'
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   jestTestsddbRun:
     needs:
@@ -379,7 +379,7 @@ jobs:
           fromJson(needs.jestTestsddbConstants.outputs.packages-to-jest-test) }}
     runs-on: ${{ matrix.os }}
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
       AWS_REGION: eu-central-1
     if: needs.jestTestsddbConstants.outputs.packages-to-jest-test != '[]'
@@ -476,7 +476,7 @@ jobs:
           echo '${{
           steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}'
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   jestTestsddb-esRun:
     needs:
@@ -497,7 +497,7 @@ jobs:
           }}
     runs-on: ${{ matrix.os }}
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
       AWS_REGION: eu-central-1
       AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }}
@@ -606,7 +606,7 @@ jobs:
           echo '${{
           steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}'
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   jestTestsddb-osRun:
     needs:
@@ -627,7 +627,7 @@ jobs:
           }}
     runs-on: ${{ matrix.os }}
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
       AWS_REGION: eu-central-1
       AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_OPEN_SEARCH_DOMAIN_NAME }}
diff --git a/.github/workflows/pullRequestsCommandCypress.yml b/.github/workflows/pullRequestsCommandCypress.yml
index 712e3e661d0..59103fd5f2c 100644
--- a/.github/workflows/pullRequestsCommandCypress.yml
+++ b/.github/workflows/pullRequestsCommandCypress.yml
@@ -3,9 +3,9 @@
 # and run "github-actions-wac build" (or "ghawac build") to regenerate this file.
 # For more information, run "github-actions-wac --help".
 name: Pull Requests Command - Cypress
-"on": issue_comment
+'on': issue_comment
 env:
-  NODE_OPTIONS: "--max_old_space_size=4096"
+  NODE_OPTIONS: '--max_old_space_size=4096'
   AWS_REGION: eu-central-1
 jobs:
   checkComment:
@@ -21,9 +21,9 @@ jobs:
         with:
           repo-token: ${{ secrets.GITHUB_TOKEN }}
           command: cypress
-          reaction: "true"
+          reaction: 'true'
           reaction-type: eyes
-          allow-edits: "false"
+          allow-edits: 'false'
           permission-level: write
       - name: Create comment
         uses: peter-evans/create-or-update-comment@v2
@@ -35,7 +35,7 @@ jobs:
             github.run_id }})). :sparkles:
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   validateWorkflows:
     name: Validate workflows
@@ -51,7 +51,7 @@ jobs:
     needs: checkComment
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   baseBranch:
     needs: checkComment
@@ -72,7 +72,7 @@ jobs:
           baseRefName -q .baseRefName)" >> $GITHUB_OUTPUT
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   constants:
     needs: baseBranch
@@ -97,7 +97,7 @@ jobs:
           vars.RANDOM_CACHE_KEY_SUFFIX }}" >> $GITHUB_OUTPUT
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   build:
     name: Build
@@ -136,7 +136,7 @@ jobs:
           path: ${{ needs.baseBranch.outputs.base-branch }}/.webiny/cached-packages
           key: ${{ needs.constants.outputs.run-cache-key }}
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   e2e-wby-cms-ddb-constants:
     needs:
@@ -172,7 +172,7 @@ jobs:
           github.run_id }}_ddb" >> $GITHUB_OUTPUT
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   e2e-wby-cms-ddb-project-setup:
     needs:
@@ -184,7 +184,7 @@ jobs:
       cypress-config: ${{ steps.save-cypress-config.outputs.cypress-config }}
     environment: next
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
       CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }}
       PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}
@@ -328,7 +328,7 @@ jobs:
           }}
     environment: next
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
       CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }}
       PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}
@@ -404,7 +404,7 @@ jobs:
           github.run_id }}_ddb-es" >> $GITHUB_OUTPUT
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   e2e-wby-cms-ddb-es-project-setup:
     needs:
@@ -416,7 +416,7 @@ jobs:
       cypress-config: ${{ steps.save-cypress-config.outputs.cypress-config }}
     environment: next
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
       CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }}
       PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}
@@ -564,7 +564,7 @@ jobs:
           }}
     environment: next
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
       CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }}
       PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}
@@ -644,7 +644,7 @@ jobs:
           github.run_id }}_ddb-os" >> $GITHUB_OUTPUT
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   e2e-wby-cms-ddb-os-project-setup:
     needs:
@@ -656,7 +656,7 @@ jobs:
       cypress-config: ${{ steps.save-cypress-config.outputs.cypress-config }}
     environment: next
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
       CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }}
       PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}
@@ -804,7 +804,7 @@ jobs:
           }}
     environment: next
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
       CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }}
       PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}
diff --git a/.github/workflows/pullRequestsCommandJest.yml b/.github/workflows/pullRequestsCommandJest.yml
index 9a5a2fe264e..87683a5ce36 100644
--- a/.github/workflows/pullRequestsCommandJest.yml
+++ b/.github/workflows/pullRequestsCommandJest.yml
@@ -3,9 +3,9 @@
 # and run "github-actions-wac build" (or "ghawac build") to regenerate this file.
 # For more information, run "github-actions-wac --help".
 name: Pull Requests Command - Jest
-"on": issue_comment
+'on': issue_comment
 env:
-  NODE_OPTIONS: "--max_old_space_size=4096"
+  NODE_OPTIONS: '--max_old_space_size=4096'
   AWS_REGION: eu-central-1
 jobs:
   checkComment:
@@ -21,9 +21,9 @@ jobs:
         with:
           repo-token: ${{ secrets.GITHUB_TOKEN }}
           command: jest
-          reaction: "true"
+          reaction: 'true'
           reaction-type: eyes
-          allow-edits: "false"
+          allow-edits: 'false'
           permission-level: write
       - name: Create comment
         uses: peter-evans/create-or-update-comment@v2
@@ -35,7 +35,7 @@ jobs:
             github.run_id }})). :sparkles:
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   validateWorkflows:
     name: Validate workflows
@@ -51,7 +51,7 @@ jobs:
     needs: checkComment
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   baseBranch:
     needs: checkComment
@@ -72,7 +72,7 @@ jobs:
           baseRefName -q .baseRefName)" >> $GITHUB_OUTPUT
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   constants:
     needs: baseBranch
@@ -97,7 +97,7 @@ jobs:
           vars.RANDOM_CACHE_KEY_SUFFIX }}" >> $GITHUB_OUTPUT
     runs-on: ubuntu-latest
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   build:
     name: Build
@@ -136,7 +136,7 @@ jobs:
           path: ${{ needs.baseBranch.outputs.base-branch }}/.webiny/cached-packages
           key: ${{ needs.constants.outputs.run-cache-key }}
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
   jestTestsNoStorage:
     needs:
@@ -156,7 +156,7 @@ jobs:
           }}
     runs-on: ${{ matrix.os }}
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
       AWS_REGION: eu-central-1
     steps:
@@ -243,7 +243,7 @@ jobs:
           }}
     runs-on: ${{ matrix.os }}
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
       AWS_REGION: eu-central-1
     steps:
@@ -330,7 +330,7 @@ jobs:
           }}
     runs-on: ${{ matrix.os }}
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
       AWS_REGION: eu-central-1
       AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }}
@@ -427,7 +427,7 @@ jobs:
           }}
     runs-on: ${{ matrix.os }}
     env:
-      NODE_OPTIONS: "--max_old_space_size=4096"
+      NODE_OPTIONS: '--max_old_space_size=4096'
       YARN_ENABLE_IMMUTABLE_INSTALLS: false
       AWS_REGION: eu-central-1
       AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_OPEN_SEARCH_DOMAIN_NAME }}

From a246137615c7480311e70b14d7e9556f4d68dee4 Mon Sep 17 00:00:00 2001
From: Adrian Smijulj <adrian1358@gmail.com>
Date: Fri, 20 Dec 2024 16:52:25 +0100
Subject: [PATCH 26/52] fix: delete settings entry on locale deletion (#4460)

---
 .../__tests__/graphql/i18n.ts                 | 16 ++++++
 .../__tests__/settings.test.ts                | 57 ++++++++++++++++---
 .../__tests__/useGqlHandler.ts                |  5 +-
 .../src/plugins/crud/index.ts                 |  7 +++
 4 files changed, 75 insertions(+), 10 deletions(-)

diff --git a/packages/api-form-builder/__tests__/graphql/i18n.ts b/packages/api-form-builder/__tests__/graphql/i18n.ts
index f33ffc38db5..86255c3126e 100644
--- a/packages/api-form-builder/__tests__/graphql/i18n.ts
+++ b/packages/api-form-builder/__tests__/graphql/i18n.ts
@@ -13,3 +13,19 @@ export const CREATE_LOCALE = /* GraphQL */ `
         }
     }
 `;
+
+export const DELETE_LOCALE = /* GraphQL */ `
+    mutation DeleteI18NLocale($code: String!) {
+        i18n {
+            deleteI18NLocale(code: $code) {
+                data {
+                    code
+                }
+                error {
+                    message
+                    code
+                }
+            }
+        }
+    }
+`;
diff --git a/packages/api-form-builder/__tests__/settings.test.ts b/packages/api-form-builder/__tests__/settings.test.ts
index 48a100cc22d..9addb5a3903 100644
--- a/packages/api-form-builder/__tests__/settings.test.ts
+++ b/packages/api-form-builder/__tests__/settings.test.ts
@@ -2,9 +2,16 @@ import useGqlHandler from "./useGqlHandler";
 import { GET_SETTINGS } from "~tests/graphql/formBuilderSettings";
 
 describe("Settings Test", () => {
-    const { getSettings, updateSettings, install, createI18NLocale, isInstalled } = useGqlHandler();
-
-    test(`Should not be able to get & update settings before "install"`, async () => {
+    const {
+        getSettings,
+        updateSettings,
+        install,
+        createI18NLocale,
+        deleteI18NLocale,
+        isInstalled
+    } = useGqlHandler();
+
+    it(`Should not be able to get & update settings before "install"`, async () => {
         // Should not have any settings without install
         const [getSettingsResponse] = await getSettings();
 
@@ -40,7 +47,7 @@ describe("Settings Test", () => {
         });
     });
 
-    test("Should be able to install `Form Builder`", async () => {
+    it("Should be able to install `Form Builder`", async () => {
         // "isInstalled" should return false prior "install"
         const [isInstalledResponse] = await isInstalled();
 
@@ -78,7 +85,7 @@ describe("Settings Test", () => {
         });
     });
 
-    test(`Should be able to get & update settings after "install"`, async () => {
+    it(`Should be able to get & update settings after "install"`, async () => {
         // Let's install the `Form builder`
         const [installResponse] = await install({ domain: "http://localhost:3001" });
 
@@ -156,7 +163,7 @@ describe("Settings Test", () => {
         });
     });
 
-    test(`Should be able to get & update settings after in a new locale`, async () => {
+    it(`Should be able to get & update settings after in a new locale`, async () => {
         // Let's install the `Form builder`
         await install({ domain: "http://localhost:3001" });
 
@@ -168,9 +175,7 @@ describe("Settings Test", () => {
         // set the locale header. Wasn't easily possible via the `getSettings` helper.
         const [newLocaleFbSettings] = await invoke({
             body: { query: GET_SETTINGS },
-            headers: {
-                "x-i18n-locale": "default:de-DE;content:de-DE;"
-            }
+            headers: { "x-i18n-locale": "default:de-DE;content:de-DE;" }
         });
 
         // Settings should exist in the newly created locale.
@@ -192,4 +197,38 @@ describe("Settings Test", () => {
             }
         });
     });
+
+    it(`Should be able to create a locale, delete it, and again create it`, async () => {
+        // Let's install the `Form builder`
+        await install({ domain: "http://localhost:3001" });
+
+        await createI18NLocale({ data: { code: "en-US" } });
+        await createI18NLocale({ data: { code: "de-DE" } });
+
+        const [deleteDeLocaleResponse] = await deleteI18NLocale({ code: "de-DE" });
+        expect(deleteDeLocaleResponse).toEqual({
+            data: {
+                i18n: {
+                    deleteI18NLocale: {
+                        data: { code: "de-DE" },
+                        error: null
+                    }
+                }
+            }
+        });
+
+        const [createDeLocaleResponse] = await createI18NLocale({ data: { code: "de-DE" } });
+        expect(createDeLocaleResponse).toEqual({
+            data: {
+                i18n: {
+                    createI18NLocale: {
+                        data: {
+                            code: "de-DE"
+                        },
+                        error: null
+                    }
+                }
+            }
+        });
+    });
 });
diff --git a/packages/api-form-builder/__tests__/useGqlHandler.ts b/packages/api-form-builder/__tests__/useGqlHandler.ts
index e5fc524dd47..8dbf4b4f1bf 100644
--- a/packages/api-form-builder/__tests__/useGqlHandler.ts
+++ b/packages/api-form-builder/__tests__/useGqlHandler.ts
@@ -12,7 +12,7 @@ import { createI18NGraphQL } from "@webiny/api-i18n/graphql";
 
 // Graphql
 import { INSTALL as INSTALL_FILE_MANAGER } from "./graphql/fileManagerSettings";
-import { CREATE_LOCALE } from "./graphql/i18n";
+import { DELETE_LOCALE, CREATE_LOCALE } from "./graphql/i18n";
 
 import {
     GET_SETTINGS,
@@ -228,6 +228,9 @@ export default (params: UseGqlHandlerParams = {}) => {
         // Locales.
         async createI18NLocale(variables: Record<string, any>) {
             return invoke({ body: { query: CREATE_LOCALE, variables } });
+        },
+        async deleteI18NLocale(variables: Record<string, any>) {
+            return invoke({ body: { query: DELETE_LOCALE, variables } });
         }
     };
 };
diff --git a/packages/api-form-builder/src/plugins/crud/index.ts b/packages/api-form-builder/src/plugins/crud/index.ts
index 0c94f6f367a..c0aaf780b26 100644
--- a/packages/api-form-builder/src/plugins/crud/index.ts
+++ b/packages/api-form-builder/src/plugins/crud/index.ts
@@ -122,6 +122,13 @@ export default (params: CreateFormBuilderCrudParams) => {
                     return context.formBuilder.createSettings({});
                 });
             });
+
+            context.i18n.locales.onLocaleAfterDelete.subscribe(async params => {
+                const { locale } = params;
+                await context.i18n.withLocale(locale, async () => {
+                    return context.formBuilder.deleteSettings();
+                });
+            });
         })
     ];
 };

From 3cc4b02023ddb4671c333805fc83cd06aa1c49da Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Mon, 23 Dec 2024 18:52:27 +0100
Subject: [PATCH 27/52] fix(app-headless-cms): remove delete callback
 memoization

---
 .../admin/components/ContentEntryForm/useBind.tsx |  7 +++++--
 .../fieldRenderers/object/multipleObjects.tsx     | 15 ++++++---------
 .../object/multipleObjectsAccordion.tsx           | 15 ++++++---------
 3 files changed, 17 insertions(+), 20 deletions(-)

diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/useBind.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/useBind.tsx
index f1bb62dc02d..772e57b9067 100644
--- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/useBind.tsx
+++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/useBind.tsx
@@ -101,8 +101,11 @@ export function useBind({ Bind, field }: UseBindProps) {
                                     if (index < 0) {
                                         return;
                                     }
-                                    let value = bind.value;
-                                    value = [...value.slice(0, index), ...value.slice(index + 1)];
+
+                                    const value = [
+                                        ...bind.value.slice(0, index),
+                                        ...bind.value.slice(index + 1)
+                                    ];
 
                                     bind.onChange(value.length === 0 ? null : value);
 
diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjects.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjects.tsx
index 4e33f8cc59e..808a7afc618 100644
--- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjects.tsx
+++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjects.tsx
@@ -68,15 +68,12 @@ const Actions = ({ setHighlightIndex, bind, index }: ActionsProps) => {
         [moveValueUp, index]
     );
 
-    const onDelete = useCallback(
-        (ev: React.BaseSyntheticEvent) => {
-            ev.stopPropagation();
-            showConfirmation(() => {
-                bind.field.removeValue(index);
-            });
-        },
-        [index]
-    );
+    const onDelete = (ev: React.BaseSyntheticEvent) => {
+        ev.stopPropagation();
+        showConfirmation(() => {
+            bind.field.removeValue(index);
+        });
+    };
 
     return (
         <>
diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx
index 24d35ac9f7f..e0cdc188114 100644
--- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx
+++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx
@@ -70,15 +70,12 @@ const Actions = ({ setHighlightIndex, bind, index }: ActionsProps) => {
         [moveValueUp, index]
     );
 
-    const onDelete = useCallback(
-        (ev: React.BaseSyntheticEvent) => {
-            ev.stopPropagation();
-            showConfirmation(() => {
-                bind.field.removeValue(index);
-            });
-        },
-        [index]
-    );
+    const onDelete = (ev: React.BaseSyntheticEvent) => {
+        ev.stopPropagation();
+        showConfirmation(() => {
+            bind.field.removeValue(index);
+        });
+    };
 
     return (
         <>

From 6e1f5ac485e11f2f61f4c29f062712a6d2e40912 Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Mon, 23 Dec 2024 20:42:21 +0100
Subject: [PATCH 28/52] fix(app-page-builder): expose a useDeleteElement hook

---
 .../src/editor/hooks/index.ts                 |  2 +
 .../src/editor/hooks/useDeleteElement.ts      | 64 +++++++++++++++++++
 .../src/editor/hooks/useFindElementBlock.ts   | 22 +++++++
 .../elementSettings/delete/DeleteAction.ts    | 59 ++---------------
 4 files changed, 95 insertions(+), 52 deletions(-)
 create mode 100644 packages/app-page-builder/src/editor/hooks/useDeleteElement.ts
 create mode 100644 packages/app-page-builder/src/editor/hooks/useFindElementBlock.ts

diff --git a/packages/app-page-builder/src/editor/hooks/index.ts b/packages/app-page-builder/src/editor/hooks/index.ts
index 827ad709e08..72385053bf7 100644
--- a/packages/app-page-builder/src/editor/hooks/index.ts
+++ b/packages/app-page-builder/src/editor/hooks/index.ts
@@ -17,3 +17,5 @@ export { useRootElement } from "./useRootElement";
 export { useUI } from "./useUI";
 export { useUpdateElement } from "./useUpdateElement";
 export { useUpdateHandlers } from "./useUpdateHandlers";
+export { useDeleteElement } from "./useDeleteElement";
+export { useFindElementBlock } from "./useFindElementBlock";
diff --git a/packages/app-page-builder/src/editor/hooks/useDeleteElement.ts b/packages/app-page-builder/src/editor/hooks/useDeleteElement.ts
new file mode 100644
index 00000000000..8bf13d2a2c5
--- /dev/null
+++ b/packages/app-page-builder/src/editor/hooks/useDeleteElement.ts
@@ -0,0 +1,64 @@
+import { useCallback } from "react";
+import { plugins } from "@webiny/plugins";
+import { useEventActionHandler, useFindElementBlock, useUpdateElement } from "~/editor";
+import { DeleteElementActionEvent } from "~/editor/recoil/actions";
+import type { PbBlockVariable, PbEditorElement, PbEditorPageElementPlugin } from "~/types";
+
+const removeVariableFromBlock = (block: PbEditorElement, variableId: string) => {
+    const variables = block.data.variables ?? [];
+
+    const updatedVariables = variables.filter(
+        (variable: PbBlockVariable) => variable.id.split(".")[0] !== variableId
+    );
+
+    return {
+        ...block,
+        data: {
+            ...block.data,
+            variables: updatedVariables
+        }
+    };
+};
+
+export const useDeleteElement = () => {
+    const eventActionHandler = useEventActionHandler();
+    const updateElement = useUpdateElement();
+    const { findElementBlock } = useFindElementBlock();
+
+    const canDeleteElement = useCallback((element: PbEditorElement) => {
+        const plugin = plugins
+            .byType<PbEditorPageElementPlugin>("pb-editor-page-element")
+            .find(pl => pl.elementType === element.type);
+
+        if (!plugin) {
+            return false;
+        }
+
+        if (typeof plugin.canDelete === "function") {
+            if (!plugin.canDelete({ element })) {
+                return false;
+            }
+        }
+
+        return true;
+    }, []);
+
+    const deleteElement = useCallback(async (element: PbEditorElement): Promise<void> => {
+        const block = await findElementBlock(element.id);
+
+        // We need to remove element variable from block if it exists
+        if (element.data?.variableId && block) {
+            const updatedBlock = removeVariableFromBlock(block, element.data.variableId);
+
+            updateElement(updatedBlock);
+        }
+
+        eventActionHandler.trigger(
+            new DeleteElementActionEvent({
+                element
+            })
+        );
+    }, []);
+
+    return { canDeleteElement, deleteElement };
+};
diff --git a/packages/app-page-builder/src/editor/hooks/useFindElementBlock.ts b/packages/app-page-builder/src/editor/hooks/useFindElementBlock.ts
new file mode 100644
index 00000000000..194840d422e
--- /dev/null
+++ b/packages/app-page-builder/src/editor/hooks/useFindElementBlock.ts
@@ -0,0 +1,22 @@
+import { useCallback } from "react";
+import { useRecoilCallback } from "recoil";
+import { blockByElementSelector } from "~/editor/hooks/useCurrentBlockElement";
+
+/**
+ * Exposes a getter which traverses the element tree upwards from the given element id, and returns an element
+ * of type "block", if found.
+ */
+export const useFindElementBlock = () => {
+    const findBlock = useRecoilCallback(({ snapshot }) => async (id: string) => {
+        return await snapshot.getPromise(blockByElementSelector(id));
+    });
+
+    const findElementBlock = useCallback(
+        async (elementId: string) => {
+            return findBlock(elementId);
+        },
+        [findBlock]
+    );
+
+    return { findElementBlock };
+};
diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/delete/DeleteAction.ts b/packages/app-page-builder/src/editor/plugins/elementSettings/delete/DeleteAction.ts
index e57cdc387b6..4a6c06dbca9 100644
--- a/packages/app-page-builder/src/editor/plugins/elementSettings/delete/DeleteAction.ts
+++ b/packages/app-page-builder/src/editor/plugins/elementSettings/delete/DeleteAction.ts
@@ -1,67 +1,22 @@
 import React, { useCallback } from "react";
-import { plugins } from "@webiny/plugins";
-import { useActiveElement, useEventActionHandler } from "~/editor";
-import { PbEditorPageElementPlugin, PbBlockVariable, PbEditorElement } from "~/types";
-import { useUpdateElement } from "~/editor/hooks/useUpdateElement";
-import { useParentBlock } from "~/editor/hooks/useParentBlock";
-import { DeleteElementActionEvent } from "~/editor/recoil/actions";
-
-const removeVariableFromBlock = (block: PbEditorElement, variableId: string) => {
-    const variables = block.data.variables ?? [];
-
-    const updatedVariables = variables.filter(
-        (variable: PbBlockVariable) => variable.id.split(".")[0] !== variableId
-    );
-
-    return {
-        ...block,
-        data: {
-            ...block.data,
-            variables: updatedVariables
-        }
-    };
-};
+import { useActiveElement, useDeleteElement } from "~/editor";
 
 interface DeleteActionPropsType {
     children: React.ReactElement;
 }
 const DeleteAction = ({ children }: DeleteActionPropsType) => {
-    const eventActionHandler = useEventActionHandler();
     const [element] = useActiveElement();
-    const block = useParentBlock();
-    const updateElement = useUpdateElement();
-
-    if (!element) {
-        return null;
-    }
+    const { deleteElement, canDeleteElement } = useDeleteElement();
 
     const onClick = useCallback((): void => {
-        // We need to remove element variable from block if it exists
-        if (element.data?.variableId && block) {
-            const updatedBlock = removeVariableFromBlock(block, element.data.variableId);
-
-            updateElement(updatedBlock);
+        if (!element) {
+            return;
         }
-        eventActionHandler.trigger(
-            new DeleteElementActionEvent({
-                element
-            })
-        );
-    }, [element.id]);
-
-    const plugin = plugins
-        .byType<PbEditorPageElementPlugin>("pb-editor-page-element")
-        .find(pl => pl.elementType === element.type);
-
-    if (!plugin) {
-        return null;
-    }
 
-    if (typeof plugin.canDelete === "function") {
-        if (!plugin.canDelete({ element })) {
-            return null;
+        if (canDeleteElement(element)) {
+            deleteElement(element);
         }
-    }
+    }, [element?.id]);
 
     return React.cloneElement(children, { onClick });
 };

From a807d100247c49285b694f528821183bfee11943 Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Mon, 13 Jan 2025 19:31:31 +0100
Subject: [PATCH 29/52] fix(react-composition): inherit decorators from parent
 scope

(cherry picked from commit bd68f63c1f41d3e124953ec97b5cfecc622dead0)
---
 packages/react-composition/src/Compose.tsx |  6 +--
 packages/react-composition/src/Context.tsx | 47 +++++++++++++++++-----
 2 files changed, 39 insertions(+), 14 deletions(-)

diff --git a/packages/react-composition/src/Compose.tsx b/packages/react-composition/src/Compose.tsx
index b91a8f7c288..a71529e3bdf 100644
--- a/packages/react-composition/src/Compose.tsx
+++ b/packages/react-composition/src/Compose.tsx
@@ -27,11 +27,7 @@ export const Compose = (props: ComposeProps) => {
         }
 
         const decorators = Array.isArray(props.with) ? props.with : [props.with];
-        return composeComponent(
-            targetFn.original,
-            decorators as Enumerable<ComposeWith>,
-            scope[scope.length - 1]
-        );
+        return composeComponent(targetFn.original, decorators as Enumerable<ComposeWith>, scope);
     }, [props.with]);
 
     return null;
diff --git a/packages/react-composition/src/Context.tsx b/packages/react-composition/src/Context.tsx
index f7c08166a48..39c9e4995c2 100644
--- a/packages/react-composition/src/Context.tsx
+++ b/packages/react-composition/src/Context.tsx
@@ -65,7 +65,7 @@ interface CompositionContext {
     composeComponent(
         component: ComponentType<unknown>,
         hocs: Enumerable<ComposeWith>,
-        scope?: string
+        scope?: string[]
     ): void;
 }
 
@@ -79,25 +79,48 @@ interface CompositionProviderProps {
     children: React.ReactNode;
 }
 
+/**
+ * Scopes are ordered in reverse, to go from child to parent. As we iterate over scopes, we try to find the latest component
+ * recipe (a "recipe" is a base component + all decorators registered so far). If none exist, we return an empty recipe.
+ */
+const findComponentRecipe = (
+    component: GenericComponent | GenericHook,
+    lookupScopes: string[],
+    components: ComponentScopes
+) => {
+    for (const scope of lookupScopes) {
+        const scopeMap: ComposedComponents = components.get(scope) || new Map();
+        const recipe = scopeMap.get(component);
+        if (recipe) {
+            return recipe;
+        }
+    }
+
+    return { component: null, hocs: [] };
+};
+
 const composeComponents = (
     components: ComponentScopes,
     decorators: Array<[GenericComponent | GenericHook, Decorator<any>[]]>,
-    scope = "*"
+    scopes: string[] = []
 ) => {
-    const scopeMap: ComposedComponents = components.get(scope) || new Map();
+    const targetScope = scopes[scopes.length - 1];
+    const targetComponents = components.get(targetScope) || new Map();
+    const lookupScopes = scopes.reverse();
+
     for (const [component, hocs] of decorators) {
-        const recipe = scopeMap.get(component) || { component: null, hocs: [] };
+        const recipe = findComponentRecipe(component, lookupScopes, components);
 
         const newHocs = [...(recipe.hocs || []), ...hocs] as Decorator<
             GenericHook | GenericComponent
         >[];
 
-        scopeMap.set(component, {
+        targetComponents.set(component, {
             component: compose(...[...newHocs].reverse())(component),
             hocs: newHocs
         });
 
-        components.set(scope, scopeMap);
+        components.set(targetScope, targetComponents);
     }
 
     return components;
@@ -109,7 +132,8 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr
             new Map(),
             decorators.map(tuple => {
                 return [tuple[0].original, tuple[1]];
-            })
+            }),
+            ["*"]
         );
     });
 
@@ -117,14 +141,19 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr
         (
             component: GenericComponent | GenericHook,
             hocs: HigherOrderComponent<any, any>[],
-            scope: string | undefined = "*"
+            scopes: string[] = []
         ) => {
             setComponents(prevComponents => {
-                return composeComponents(new Map(prevComponents), [[component, hocs]], scope);
+                return composeComponents(
+                    new Map(prevComponents),
+                    [[component, hocs]],
+                    ["*", ...scopes]
+                );
             });
 
             // Return a function that will remove the added HOCs.
             return () => {
+                const scope = scopes[scopes.length - 1];
                 setComponents(prevComponents => {
                     const components = new Map(prevComponents);
                     const scopeMap: ComposedComponents = components.get(scope) || new Map();

From 4bdee89dc8f686e4f832ddc890d6376021525d1b Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Mon, 30 Dec 2024 12:56:04 +0100
Subject: [PATCH 30/52] fix(app-headless-cms): toggle overflow when accordion
 is expanded

(cherry picked from commit 18b604eb0c32b3366fa2febb69c5531dfd91dda6)
---
 .../plugins/fieldRenderers/Accordion.tsx      | 46 ++++++++++++-------
 1 file changed, 29 insertions(+), 17 deletions(-)

diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/Accordion.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/Accordion.tsx
index 9c532a38486..daf386fadab 100644
--- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/Accordion.tsx
+++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/Accordion.tsx
@@ -1,5 +1,6 @@
 import React, { ReactElement, useCallback, useState } from "react";
 import { css } from "emotion";
+import styled from "@emotion/styled";
 import classNames from "classnames";
 import { Typography } from "@webiny/ui/Typography";
 
@@ -69,23 +70,34 @@ const classes = {
                 transform: "translateY(3px) rotate(90deg)"
             }
         }
-    }),
-    accordionItem: css({
-        overflow: "hidden",
-        transition: "max-height 0.3s cubic-bezier(1, 0, 1, 0)",
-        height: "auto",
-        maxHeight: "9999px",
-
-        "&.collapsed": {
-            maxHeight: 0,
-            transition: "max-height 0.35s cubic-bezier(0, 1, 0, 1)"
-        },
-        ".accordion-content": {
-            paddingBottom: 10
-        }
     })
 };
 
+const AccordionItem = styled.div`
+    @keyframes show-overflow {
+        to {
+            overflow: visible;
+        }
+    }
+    overflow: hidden;
+    transition: max-height 0.35s cubic-bezier(0, 1, 0, 1);
+    height: auto;
+    max-height: 0;
+
+    &.expanded {
+        max-height: 9999px;
+        transition: max-height 0.3s cubic-bezier(1, 0, 1, 0);
+        animation-name: show-overflow;
+        animation-fill-mode: forwards;
+        animation-duration: 20ms;
+        animation-delay: 0.3s;
+    }
+`;
+
+const AccordionContent = styled.div`
+    padding-bottom: 10;
+`;
+
 interface AccordionProps {
     title: string;
     action?: ReactElement | null;
@@ -116,9 +128,9 @@ const Accordion = ({ title, children, action, icon, defaultValue = false }: Acco
                     <div className={"icon-container"}>{icon}</div>
                 </div>
             </div>
-            <div className={classNames(classes.accordionItem, { collapsed: !isOpen })}>
-                <div className="accordion-content">{children}</div>
-            </div>
+            <AccordionItem className={classNames({ expanded: isOpen })}>
+                <AccordionContent>{children}</AccordionContent>
+            </AccordionItem>
         </div>
     );
 };

From daa4faaafec4cd4053c64028d18806ea25b2c583 Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Sun, 5 Jan 2025 15:00:02 +0100
Subject: [PATCH 31/52] fix(ui): use CSS variable instead of hardcoded values

(cherry picked from commit 70d6ade4d37164a86c22a77227fcd5dfe762964e)
---
 packages/ui/src/AutoComplete/MultiAutoComplete.tsx | 11 +++++++++--
 1 file changed, 9 insertions(+), 2 deletions(-)

diff --git a/packages/ui/src/AutoComplete/MultiAutoComplete.tsx b/packages/ui/src/AutoComplete/MultiAutoComplete.tsx
index 1f5874e0ded..5e3f61941dd 100644
--- a/packages/ui/src/AutoComplete/MultiAutoComplete.tsx
+++ b/packages/ui/src/AutoComplete/MultiAutoComplete.tsx
@@ -37,7 +37,7 @@ const style = {
             display: "flex",
             justifyContent: "space-between",
             alignItems: "center",
-            borderBottom: "2px solid #fa5723",
+            borderBottom: "2px solid var(--mdc-theme-primary, #fa5723)",
             padding: "6px 0"
         }),
         pages: css({
@@ -128,7 +128,14 @@ interface MultiAutoCompleteState {
 }
 
 const Spinner = () => {
-    return <MaterialSpinner size={24} spinnerColor={"#fa5723"} spinnerWidth={2} visible />;
+    return (
+        <MaterialSpinner
+            size={24}
+            spinnerColor={"var(--mdc-theme-primary, #fa5723)"}
+            spinnerWidth={2}
+            visible
+        />
+    );
 };
 
 interface RenderOptionsParams

From cb9c54ca0bc1c148329d6735501f7c55b87094f8 Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Fri, 17 Jan 2025 11:56:52 +0100
Subject: [PATCH 32/52] fix(react-composition): inherit components from parent
 scopes (#4494)

---
 .../FileManagerView/FileManagerViewConfig.tsx | 13 ++++-
 .../configComponents/Browser/FileAction.tsx   |  9 ++--
 .../configComponents/Browser/FolderAction.tsx |  9 ++--
 .../configComponents/Browser/Table/Column.tsx |  9 ++--
 .../LexicalCmsEditor/LexicalCmsEditor.tsx     |  7 +--
 .../Browser/AdvancedSearch/FieldRenderer.tsx  |  9 ++--
 .../list/Browser/EntryAction.tsx              | 14 +++---
 .../list/Browser/FolderAction.tsx             |  9 ++--
 .../list/Browser/Table/Column.tsx             | 14 +++---
 .../list/ContentEntryListConfig.tsx           | 15 +++++-
 .../pages/list/Browser/FolderAction.tsx       |  9 ++--
 .../config/pages/list/Browser/PageAction.tsx  |  9 ++--
 .../pages/list/Browser/Table/Column.tsx       | 13 +++--
 .../config/pages/list/PageListConfig.tsx      | 15 +++++-
 .../src/admin/plugins/routes.tsx              |  2 +-
 .../configs/list/Browser/Table/Column.tsx     |  9 ++--
 .../configs/list/Browser/Table/Sorting.tsx    |  9 ++--
 .../configs/list/TrashBinListConfig.tsx       | 15 +++++-
 .../lexical-editor-pb-element/src/index.tsx   | 12 +++--
 packages/react-composition/src/Context.tsx    | 49 ++++++++++++-------
 .../react-composition/src/makeDecoratable.tsx |  4 +-
 21 files changed, 139 insertions(+), 115 deletions(-)

diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/FileManagerViewConfig.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/FileManagerViewConfig.tsx
index 4f6e791627c..4a19b47c1b0 100644
--- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/FileManagerViewConfig.tsx
+++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/FileManagerViewConfig.tsx
@@ -1,11 +1,22 @@
-import { useMemo } from "react";
+import React, { useMemo } from "react";
 import { createConfigurableComponent } from "@webiny/react-properties";
 import { Browser, BrowserConfig } from "./configComponents/Browser";
 import { FileDetails, FileDetailsConfig } from "./configComponents/FileDetails";
 import { getThumbnailRenderer } from "./getThumbnailRenderer";
+import { CompositionScope } from "@webiny/react-composition";
 
 const base = createConfigurableComponent<FileManagerViewConfigData>("FileManagerView");
 
+const ScopedFileManagerViewConfig = ({ children }: { children: React.ReactNode }) => {
+    return (
+        <CompositionScope name={"fm"}>
+            <base.Config>{children}</base.Config>
+        </CompositionScope>
+    );
+};
+
+ScopedFileManagerViewConfig.displayName = "FileManagerViewConfig";
+
 export const FileManagerViewConfig = Object.assign(base.Config, { Browser, FileDetails });
 export const FileManagerViewWithConfig = base.WithConfig;
 
diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FileAction.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FileAction.tsx
index 9c4b414cb48..ede69cf5f4c 100644
--- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FileAction.tsx
+++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FileAction.tsx
@@ -1,5 +1,4 @@
 import React from "react";
-import { CompositionScope } from "@webiny/react-composition";
 import { AcoConfig, RecordActionConfig } from "@webiny/app-aco";
 
 const { Record } = AcoConfig;
@@ -10,11 +9,9 @@ type FileActionProps = React.ComponentProps<typeof AcoConfig.Record.Action>;
 
 const BaseFileAction = (props: FileActionProps) => {
     return (
-        <CompositionScope name={"fm"}>
-            <AcoConfig>
-                <Record.Action {...props} />
-            </AcoConfig>
-        </CompositionScope>
+        <AcoConfig>
+            <Record.Action {...props} />
+        </AcoConfig>
     );
 };
 
diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FolderAction.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FolderAction.tsx
index f019f44b279..7839116b234 100644
--- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FolderAction.tsx
+++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FolderAction.tsx
@@ -1,5 +1,4 @@
 import React from "react";
-import { CompositionScope } from "@webiny/react-composition";
 import { AcoConfig, FolderActionConfig } from "@webiny/app-aco";
 
 const { Folder } = AcoConfig;
@@ -10,11 +9,9 @@ type FolderActionProps = React.ComponentProps<typeof AcoConfig.Folder.Action>;
 
 const BaseFolderAction = (props: FolderActionProps) => {
     return (
-        <CompositionScope name={"fm"}>
-            <AcoConfig>
-                <Folder.Action {...props} />
-            </AcoConfig>
-        </CompositionScope>
+        <AcoConfig>
+            <Folder.Action {...props} />
+        </AcoConfig>
     );
 };
 
diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/Table/Column.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/Table/Column.tsx
index 91c21eb81a5..719bfb45e70 100644
--- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/Table/Column.tsx
+++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/Table/Column.tsx
@@ -1,5 +1,4 @@
 import React from "react";
-import { CompositionScope } from "@webiny/react-composition";
 import { AcoConfig, TableColumnConfig as ColumnConfig } from "@webiny/app-aco";
 import { TableItem } from "~/types";
 
@@ -11,11 +10,9 @@ type ColumnProps = React.ComponentProps<typeof AcoConfig.Table.Column>;
 
 const BaseColumn = (props: ColumnProps) => {
     return (
-        <CompositionScope name={"fm"}>
-            <AcoConfig>
-                <Table.Column {...props} />
-            </AcoConfig>
-        </CompositionScope>
+        <AcoConfig>
+            <Table.Column {...props} />
+        </AcoConfig>
     );
 };
 
diff --git a/packages/app-headless-cms/src/admin/components/LexicalCmsEditor/LexicalCmsEditor.tsx b/packages/app-headless-cms/src/admin/components/LexicalCmsEditor/LexicalCmsEditor.tsx
index 925468df5af..60469ba8715 100644
--- a/packages/app-headless-cms/src/admin/components/LexicalCmsEditor/LexicalCmsEditor.tsx
+++ b/packages/app-headless-cms/src/admin/components/LexicalCmsEditor/LexicalCmsEditor.tsx
@@ -1,7 +1,6 @@
 import React, { useCallback } from "react";
 import { StaticToolbar } from "@webiny/lexical-editor";
 import { RichTextEditorProps } from "@webiny/lexical-editor/types";
-import { CompositionScope } from "@webiny/react-composition";
 import { LexicalEditor } from "@webiny/app-admin/components/LexicalEditor";
 
 const placeholderStyles: React.CSSProperties = { position: "absolute", top: 40, left: 25 };
@@ -20,11 +19,7 @@ const styles: React.CSSProperties = {
     maxHeight: 350
 };
 
-const toolbar = (
-    <CompositionScope name={"cms"}>
-        <StaticToolbar />
-    </CompositionScope>
-);
+const toolbar = <StaticToolbar />;
 
 export const LexicalCmsEditor = (props: Omit<RichTextEditorProps, "theme">) => {
     const onChange = useCallback(
diff --git a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/AdvancedSearch/FieldRenderer.tsx b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/AdvancedSearch/FieldRenderer.tsx
index b240d2f8cc7..cb021a5122d 100644
--- a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/AdvancedSearch/FieldRenderer.tsx
+++ b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/AdvancedSearch/FieldRenderer.tsx
@@ -1,5 +1,4 @@
 import React from "react";
-import { CompositionScope } from "@webiny/react-composition";
 import {
     AcoConfig,
     AdvancedSearchFieldRendererConfig as FieldRendererConfig
@@ -23,11 +22,9 @@ const BaseFieldRenderer = ({ modelIds = [], ...props }: FieldRendererProps) => {
     }
 
     return (
-        <CompositionScope name={"cms"}>
-            <AcoConfig>
-                <AdvancedSearch.FieldRenderer {...props} />
-            </AcoConfig>
-        </CompositionScope>
+        <AcoConfig>
+            <AdvancedSearch.FieldRenderer {...props} />
+        </AcoConfig>
     );
 };
 
diff --git a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/EntryAction.tsx b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/EntryAction.tsx
index ea5c9c8a216..00bf9ef8f22 100644
--- a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/EntryAction.tsx
+++ b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/EntryAction.tsx
@@ -1,5 +1,5 @@
 import React from "react";
-import { CompositionScope, makeDecoratable } from "@webiny/react-composition";
+import { makeDecoratable } from "@webiny/react-composition";
 import { AcoConfig, RecordActionConfig } from "@webiny/app-aco";
 import { IsApplicableToCurrentModel } from "~/admin/config/IsApplicableToCurrentModel";
 
@@ -15,13 +15,11 @@ const BaseEntryAction = makeDecoratable(
     "EntryAction",
     ({ modelIds = [], ...props }: EntryActionProps) => {
         return (
-            <CompositionScope name={"cms"}>
-                <AcoConfig>
-                    <IsApplicableToCurrentModel modelIds={modelIds}>
-                        <Record.Action {...props} />
-                    </IsApplicableToCurrentModel>
-                </AcoConfig>
-            </CompositionScope>
+            <AcoConfig>
+                <IsApplicableToCurrentModel modelIds={modelIds}>
+                    <Record.Action {...props} />
+                </IsApplicableToCurrentModel>
+            </AcoConfig>
         );
     }
 );
diff --git a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/FolderAction.tsx b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/FolderAction.tsx
index 53f2c20001b..40d950d578f 100644
--- a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/FolderAction.tsx
+++ b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/FolderAction.tsx
@@ -1,5 +1,4 @@
 import React from "react";
-import { CompositionScope } from "@webiny/react-composition";
 import { AcoConfig, FolderActionConfig } from "@webiny/app-aco";
 import { useModel } from "~/admin/hooks";
 
@@ -19,11 +18,9 @@ const BaseFolderAction = ({ modelIds = [], ...props }: FolderActionProps) => {
     }
 
     return (
-        <CompositionScope name={"cms"}>
-            <AcoConfig>
-                <Folder.Action {...props} />
-            </AcoConfig>
-        </CompositionScope>
+        <AcoConfig>
+            <Folder.Action {...props} />
+        </AcoConfig>
     );
 };
 
diff --git a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/Table/Column.tsx b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/Table/Column.tsx
index d71110f2f93..e1ebeb81801 100644
--- a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/Table/Column.tsx
+++ b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/Table/Column.tsx
@@ -1,5 +1,5 @@
 import React from "react";
-import { CompositionScope, makeDecoratable } from "@webiny/react-composition";
+import { makeDecoratable } from "@webiny/react-composition";
 import { AcoConfig, TableColumnConfig as ColumnConfig } from "@webiny/app-aco";
 import { TableItem } from "~/types";
 import { IsApplicableToCurrentModel } from "~/admin/config/IsApplicableToCurrentModel";
@@ -14,13 +14,11 @@ export interface ColumnProps extends React.ComponentProps<typeof AcoConfig.Table
 
 const BaseColumnComponent = ({ modelIds = [], ...props }: ColumnProps) => {
     return (
-        <CompositionScope name={"cms"}>
-            <AcoConfig>
-                <IsApplicableToCurrentModel modelIds={modelIds}>
-                    <Table.Column {...props} />
-                </IsApplicableToCurrentModel>
-            </AcoConfig>
-        </CompositionScope>
+        <AcoConfig>
+            <IsApplicableToCurrentModel modelIds={modelIds}>
+                <Table.Column {...props} />
+            </IsApplicableToCurrentModel>
+        </AcoConfig>
     );
 };
 
diff --git a/packages/app-headless-cms/src/admin/config/contentEntries/list/ContentEntryListConfig.tsx b/packages/app-headless-cms/src/admin/config/contentEntries/list/ContentEntryListConfig.tsx
index 5a383dc7529..a4d036cc30e 100644
--- a/packages/app-headless-cms/src/admin/config/contentEntries/list/ContentEntryListConfig.tsx
+++ b/packages/app-headless-cms/src/admin/config/contentEntries/list/ContentEntryListConfig.tsx
@@ -1,10 +1,21 @@
-import { useMemo } from "react";
+import React, { useMemo } from "react";
 import { createConfigurableComponent } from "@webiny/react-properties";
 import { Browser, BrowserConfig } from "./Browser";
+import { CompositionScope } from "@webiny/react-composition";
 
 const base = createConfigurableComponent<ContentEntryListConfig>("ContentEntryListConfig");
 
-export const ContentEntryListConfig = Object.assign(base.Config, { Browser });
+const ScopedContentEntryListConfig = ({ children }: { children: React.ReactNode }) => {
+    return (
+        <CompositionScope name={"cms"}>
+            <base.Config>{children}</base.Config>
+        </CompositionScope>
+    );
+};
+
+ScopedContentEntryListConfig.displayName = "ContentEntryListConfig";
+
+export const ContentEntryListConfig = Object.assign(ScopedContentEntryListConfig, { Browser });
 export const ContentEntryListWithConfig = base.WithConfig;
 
 interface ContentEntryListConfig {
diff --git a/packages/app-page-builder/src/admin/config/pages/list/Browser/FolderAction.tsx b/packages/app-page-builder/src/admin/config/pages/list/Browser/FolderAction.tsx
index 4cb11debe14..7839116b234 100644
--- a/packages/app-page-builder/src/admin/config/pages/list/Browser/FolderAction.tsx
+++ b/packages/app-page-builder/src/admin/config/pages/list/Browser/FolderAction.tsx
@@ -1,5 +1,4 @@
 import React from "react";
-import { CompositionScope } from "@webiny/react-composition";
 import { AcoConfig, FolderActionConfig } from "@webiny/app-aco";
 
 const { Folder } = AcoConfig;
@@ -10,11 +9,9 @@ type FolderActionProps = React.ComponentProps<typeof AcoConfig.Folder.Action>;
 
 const BaseFolderAction = (props: FolderActionProps) => {
     return (
-        <CompositionScope name={"pb.page"}>
-            <AcoConfig>
-                <Folder.Action {...props} />
-            </AcoConfig>
-        </CompositionScope>
+        <AcoConfig>
+            <Folder.Action {...props} />
+        </AcoConfig>
     );
 };
 
diff --git a/packages/app-page-builder/src/admin/config/pages/list/Browser/PageAction.tsx b/packages/app-page-builder/src/admin/config/pages/list/Browser/PageAction.tsx
index b30ac8369e5..6769638c4a7 100644
--- a/packages/app-page-builder/src/admin/config/pages/list/Browser/PageAction.tsx
+++ b/packages/app-page-builder/src/admin/config/pages/list/Browser/PageAction.tsx
@@ -1,5 +1,4 @@
 import React from "react";
-import { CompositionScope } from "@webiny/react-composition";
 import { AcoConfig, RecordActionConfig } from "@webiny/app-aco";
 
 const { Record } = AcoConfig;
@@ -10,11 +9,9 @@ type PageActionProps = React.ComponentProps<typeof AcoConfig.Record.Action>;
 
 const BasePageAction = (props: PageActionProps) => {
     return (
-        <CompositionScope name={"pb.page"}>
-            <AcoConfig>
-                <Record.Action {...props} />
-            </AcoConfig>
-        </CompositionScope>
+        <AcoConfig>
+            <Record.Action {...props} />
+        </AcoConfig>
     );
 };
 
diff --git a/packages/app-page-builder/src/admin/config/pages/list/Browser/Table/Column.tsx b/packages/app-page-builder/src/admin/config/pages/list/Browser/Table/Column.tsx
index d43d6e36b8c..a7f89ac404f 100644
--- a/packages/app-page-builder/src/admin/config/pages/list/Browser/Table/Column.tsx
+++ b/packages/app-page-builder/src/admin/config/pages/list/Browser/Table/Column.tsx
@@ -1,5 +1,4 @@
 import React from "react";
-import { CompositionScope } from "@webiny/react-composition";
 import { AcoConfig, TableColumnConfig as ColumnConfig } from "@webiny/app-aco";
 import { TableItem } from "~/types";
 
@@ -9,13 +8,11 @@ export { ColumnConfig };
 
 type ColumnProps = React.ComponentProps<typeof AcoConfig.Table.Column>;
 
-const BaseColumn = (props: ColumnProps) => {
+const BaseColumn: React.FC<ColumnProps> = props => {
     return (
-        <CompositionScope name={"pb.page"}>
-            <AcoConfig>
-                <Table.Column {...props} />
-            </AcoConfig>
-        </CompositionScope>
+        <AcoConfig>
+            <Table.Column {...props} />
+        </AcoConfig>
     );
 };
 
@@ -23,3 +20,5 @@ export const Column = Object.assign(BaseColumn, {
     useTableRow: Table.Column.createUseTableRow<TableItem>(),
     isFolderRow: Table.Column.isFolderRow
 });
+
+Column["displayName"] = "Column";
diff --git a/packages/app-page-builder/src/admin/config/pages/list/PageListConfig.tsx b/packages/app-page-builder/src/admin/config/pages/list/PageListConfig.tsx
index 5a7485790f2..929a98ee9fb 100644
--- a/packages/app-page-builder/src/admin/config/pages/list/PageListConfig.tsx
+++ b/packages/app-page-builder/src/admin/config/pages/list/PageListConfig.tsx
@@ -1,10 +1,21 @@
-import { useMemo } from "react";
+import React, { useMemo } from "react";
 import { createConfigurableComponent } from "@webiny/react-properties";
 import { Browser, BrowserConfig } from "./Browser";
+import { CompositionScope } from "@webiny/react-composition";
 
 const base = createConfigurableComponent<PageListConfig>("PageListConfig");
 
-export const PageListConfig = Object.assign(base.Config, { Browser });
+const ScopedPagesListConfig = ({ children }: { children: React.ReactNode }) => {
+    return (
+        <CompositionScope name={"pb.pages"}>
+            <base.Config>{children}</base.Config>
+        </CompositionScope>
+    );
+};
+
+ScopedPagesListConfig.displayName = "PagesListConfig";
+
+export const PageListConfig = Object.assign(ScopedPagesListConfig, { Browser });
 export const PageListWithConfig = base.WithConfig;
 
 interface PageListConfig {
diff --git a/packages/app-page-builder/src/admin/plugins/routes.tsx b/packages/app-page-builder/src/admin/plugins/routes.tsx
index 8cd18ad6eb2..342d2794bae 100644
--- a/packages/app-page-builder/src/admin/plugins/routes.tsx
+++ b/packages/app-page-builder/src/admin/plugins/routes.tsx
@@ -71,7 +71,7 @@ const plugins: RoutePlugin[] = [
                         <RenderPluginsLoader>
                             <AdminLayout>
                                 <Helmet title={"Page Builder - Pages"} />
-                                <CompositionScope name={"pb.page"}>
+                                <CompositionScope name={"pb.pages"}>
                                     <Pages />
                                 </CompositionScope>
                             </AdminLayout>
diff --git a/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Column.tsx b/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Column.tsx
index 14462c1d997..84a5cb0cd8b 100644
--- a/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Column.tsx
+++ b/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Column.tsx
@@ -1,5 +1,4 @@
 import React from "react";
-import { CompositionScope } from "@webiny/react-composition";
 import { AcoConfig, TableColumnConfig as ColumnConfig } from "@webiny/app-aco";
 import { TrashBinItemDTO } from "~/Domain";
 
@@ -11,11 +10,9 @@ type ColumnProps = React.ComponentProps<typeof AcoConfig.Table.Column>;
 
 const BaseColumn = (props: ColumnProps) => {
     return (
-        <CompositionScope name={"trash"}>
-            <AcoConfig>
-                <Table.Column {...props} />
-            </AcoConfig>
-        </CompositionScope>
+        <AcoConfig>
+            <Table.Column {...props} />
+        </AcoConfig>
     );
 };
 
diff --git a/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Sorting.tsx b/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Sorting.tsx
index cf282678ce9..de3bd4c283d 100644
--- a/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Sorting.tsx
+++ b/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Sorting.tsx
@@ -1,5 +1,4 @@
 import React from "react";
-import { CompositionScope } from "@webiny/react-composition";
 import { AcoConfig, TableSortingConfig as SortingConfig } from "@webiny/app-aco";
 
 const { Table } = AcoConfig;
@@ -10,10 +9,8 @@ type SortingProps = React.ComponentProps<typeof AcoConfig.Table.Sorting>;
 
 export const Sorting = (props: SortingProps) => {
     return (
-        <CompositionScope name={"trash"}>
-            <AcoConfig>
-                <Table.Sorting {...props} />
-            </AcoConfig>
-        </CompositionScope>
+        <AcoConfig>
+            <Table.Sorting {...props} />
+        </AcoConfig>
     );
 };
diff --git a/packages/app-trash-bin/src/Presentation/configs/list/TrashBinListConfig.tsx b/packages/app-trash-bin/src/Presentation/configs/list/TrashBinListConfig.tsx
index 2960021e3c2..378cf77d120 100644
--- a/packages/app-trash-bin/src/Presentation/configs/list/TrashBinListConfig.tsx
+++ b/packages/app-trash-bin/src/Presentation/configs/list/TrashBinListConfig.tsx
@@ -1,10 +1,21 @@
-import { useMemo } from "react";
+import React, { useMemo } from "react";
 import { createConfigurableComponent } from "@webiny/react-properties";
 import { Browser, BrowserConfig } from "./Browser";
+import { CompositionScope } from "@webiny/react-composition";
 
 const base = createConfigurableComponent<TrashBinListConfig>("TrashBinListConfig");
 
-export const TrashBinListConfig = Object.assign(base.Config, { Browser });
+const ScopedTrashBinListConfig = ({ children }: { children: React.ReactNode }) => {
+    return (
+        <CompositionScope name={"trash"}>
+            <base.Config>{children}</base.Config>
+        </CompositionScope>
+    );
+};
+
+ScopedTrashBinListConfig.displayName = "TrashBinListConfig";
+
+export const TrashBinListConfig = Object.assign(ScopedTrashBinListConfig, { Browser });
 export const TrashBinListWithConfig = base.WithConfig;
 
 interface TrashBinListConfig {
diff --git a/packages/lexical-editor-pb-element/src/index.tsx b/packages/lexical-editor-pb-element/src/index.tsx
index 48a3440f4b4..8449f332b5c 100644
--- a/packages/lexical-editor-pb-element/src/index.tsx
+++ b/packages/lexical-editor-pb-element/src/index.tsx
@@ -14,11 +14,13 @@ export * from "./LexicalEditor";
 export const LexicalEditorPlugin = () => {
     return (
         <>
-            <CompositionScope name={"pb.paragraph"}>
-                <ParagraphEditorPreset />
-            </CompositionScope>
-            <CompositionScope name={"pb.heading"}>
-                <HeadingEditorPreset />
+            <CompositionScope name={"pb.pageEditor"}>
+                <CompositionScope name={"pb.paragraph"}>
+                    <ParagraphEditorPreset />
+                </CompositionScope>
+                <CompositionScope name={"pb.heading"}>
+                    <HeadingEditorPreset />
+                </CompositionScope>
             </CompositionScope>
             <TypographyAction.TypographyDropDown element={<TypographyDropDown />} />
             {/* Block editor variables */}
diff --git a/packages/react-composition/src/Context.tsx b/packages/react-composition/src/Context.tsx
index 39c9e4995c2..457c887a31f 100644
--- a/packages/react-composition/src/Context.tsx
+++ b/packages/react-composition/src/Context.tsx
@@ -3,6 +3,7 @@ import React, {
     createContext,
     useCallback,
     useContext,
+    useEffect,
     useMemo,
     useState
 } from "react";
@@ -79,6 +80,10 @@ interface CompositionProviderProps {
     children: React.ReactNode;
 }
 
+const getCacheKey = (scopes: string[]) => {
+    return scopes.join(";");
+};
+
 /**
  * Scopes are ordered in reverse, to go from child to parent. As we iterate over scopes, we try to find the latest component
  * recipe (a "recipe" is a base component + all decorators registered so far). If none exist, we return an empty recipe.
@@ -88,8 +93,9 @@ const findComponentRecipe = (
     lookupScopes: string[],
     components: ComponentScopes
 ) => {
-    for (const scope of lookupScopes) {
-        const scopeMap: ComposedComponents = components.get(scope) || new Map();
+    for (let i = lookupScopes.length; i > 0; i--) {
+        const cacheKey = getCacheKey(lookupScopes.slice(0, i));
+        const scopeMap: ComposedComponents = components.get(cacheKey) || new Map();
         const recipe = scopeMap.get(component);
         if (recipe) {
             return recipe;
@@ -104,12 +110,11 @@ const composeComponents = (
     decorators: Array<[GenericComponent | GenericHook, Decorator<any>[]]>,
     scopes: string[] = []
 ) => {
-    const targetScope = scopes[scopes.length - 1];
-    const targetComponents = components.get(targetScope) || new Map();
-    const lookupScopes = scopes.reverse();
+    const cacheKey = getCacheKey(scopes);
+    const targetComponents: ComposedComponents = components.get(cacheKey) || new Map();
 
     for (const [component, hocs] of decorators) {
-        const recipe = findComponentRecipe(component, lookupScopes, components);
+        const recipe = findComponentRecipe(component, scopes, components);
 
         const newHocs = [...(recipe.hocs || []), ...hocs] as Decorator<
             GenericHook | GenericComponent
@@ -120,7 +125,7 @@ const composeComponents = (
             hocs: newHocs
         });
 
-        components.set(targetScope, targetComponents);
+        components.set(cacheKey, targetComponents);
     }
 
     return components;
@@ -143,20 +148,18 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr
             hocs: HigherOrderComponent<any, any>[],
             scopes: string[] = []
         ) => {
+            const allScopes = ["*", ...scopes];
+
             setComponents(prevComponents => {
-                return composeComponents(
-                    new Map(prevComponents),
-                    [[component, hocs]],
-                    ["*", ...scopes]
-                );
+                return composeComponents(new Map(prevComponents), [[component, hocs]], allScopes);
             });
 
             // Return a function that will remove the added HOCs.
             return () => {
-                const scope = scopes[scopes.length - 1];
+                const cacheKey = getCacheKey(allScopes);
                 setComponents(prevComponents => {
                     const components = new Map(prevComponents);
-                    const scopeMap: ComposedComponents = components.get(scope) || new Map();
+                    const scopeMap: ComposedComponents = components.get(cacheKey) || new Map();
                     const recipe = scopeMap.get(component) || {
                         component: null,
                         hocs: []
@@ -170,7 +173,7 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr
                         hocs: newHOCs
                     });
 
-                    components.set(scope, scopeMap);
+                    components.set(cacheKey, scopeMap);
                     return components;
                 });
             };
@@ -180,9 +183,10 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr
 
     const getComponent = useCallback<CompositionContextGetComponentCallable>(
         (Component, scope = []) => {
-            const scopesToResolve = ["*", ...scope].reverse();
-            for (const scope of scopesToResolve) {
-                const scopeMap: ComposedComponents = components.get(scope) || new Map();
+            const scopes = ["*", ...scope];
+            for (let i = scopes.length; i > 0; i--) {
+                const cacheKey = getCacheKey(scopes.slice(0, i));
+                const scopeMap: ComposedComponents = components.get(cacheKey) || new Map();
                 const composedComponent = scopeMap.get(Component);
                 if (composedComponent) {
                     return composedComponent.component;
@@ -203,6 +207,15 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr
         [components, composeComponent]
     );
 
+    useEffect(() => {
+        if (process.env.NODE_ENV !== "production") {
+            // @ts-expect-error This is a developers-only utility.
+            window["debug_printComposedComponents"] = () => {
+                console.log(components);
+            };
+        }
+    }, [components]);
+
     return <CompositionContext.Provider value={context}>{children}</CompositionContext.Provider>;
 };
 
diff --git a/packages/react-composition/src/makeDecoratable.tsx b/packages/react-composition/src/makeDecoratable.tsx
index eeea12f73b7..9310b10463d 100644
--- a/packages/react-composition/src/makeDecoratable.tsx
+++ b/packages/react-composition/src/makeDecoratable.tsx
@@ -77,7 +77,9 @@ export function makeDecoratable<T extends GenericComponent>(
 ): ReturnType<typeof makeDecoratableComponent<T>>;
 export function makeDecoratable(hookOrName: any, Component?: any) {
     if (Component) {
-        return makeDecoratableComponent(hookOrName, React.memo(Component));
+        const component = makeDecoratableComponent(hookOrName, React.memo(Component));
+        component.original.displayName = hookOrName;
+        return component;
     }
 
     return makeDecoratableHook(hookOrName);

From 922a47688dd519ddc10b598d3ae79cb3186e3173 Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Fri, 17 Jan 2025 17:15:06 +0100
Subject: [PATCH 33/52] fix(lexical-editor): ensure overlays are within the
 viewport

---
 packages/lexical-editor/package.json          |   1 -
 packages/lexical-editor/src/commands/index.ts |   1 +
 .../lexical-editor/src/commands/toolbar.ts    |   3 +
 .../src/components/Toolbar/Toolbar.tsx        |  12 ++
 .../FloatingLinkEditorPlugin.tsx              |   3 +-
 .../useFloatingLinkEditor.tsx                 | 106 ++++++++++++------
 .../src/utils/setFloatingElemPosition.ts      |  56 +++++----
 yarn.lock                                     |   1 -
 8 files changed, 116 insertions(+), 67 deletions(-)
 create mode 100644 packages/lexical-editor/src/commands/toolbar.ts

diff --git a/packages/lexical-editor/package.json b/packages/lexical-editor/package.json
index 7d3e0b426c1..4915f6851f0 100644
--- a/packages/lexical-editor/package.json
+++ b/packages/lexical-editor/package.json
@@ -21,7 +21,6 @@
     "@webiny/react-properties": "0.0.0",
     "emotion": "^10.0.17",
     "lexical": "^0.16.1",
-    "lodash": "4.17.21",
     "react": "18.2.0",
     "react-dom": "18.2.0"
   },
diff --git a/packages/lexical-editor/src/commands/index.ts b/packages/lexical-editor/src/commands/index.ts
index c49e77dead5..8bd94941554 100644
--- a/packages/lexical-editor/src/commands/index.ts
+++ b/packages/lexical-editor/src/commands/index.ts
@@ -1,3 +1,4 @@
 export * from "~/commands/image";
 export * from "~/commands/list";
 export * from "~/commands/quote";
+export * from "~/commands/toolbar";
diff --git a/packages/lexical-editor/src/commands/toolbar.ts b/packages/lexical-editor/src/commands/toolbar.ts
new file mode 100644
index 00000000000..675c35273e6
--- /dev/null
+++ b/packages/lexical-editor/src/commands/toolbar.ts
@@ -0,0 +1,3 @@
+import { createCommand } from "lexical";
+
+export const HIDE_FLOATING_TOOLBAR = createCommand("HIDE_FLOATING_TOOLBAR_COMMAND");
diff --git a/packages/lexical-editor/src/components/Toolbar/Toolbar.tsx b/packages/lexical-editor/src/components/Toolbar/Toolbar.tsx
index 93df7cf33a0..481549ba4b5 100644
--- a/packages/lexical-editor/src/components/Toolbar/Toolbar.tsx
+++ b/packages/lexical-editor/src/components/Toolbar/Toolbar.tsx
@@ -15,6 +15,7 @@ import { useLexicalEditorConfig } from "~/components/LexicalEditorConfig/Lexical
 import { useDeriveValueFromSelection } from "~/hooks/useCurrentSelection";
 import { useRichTextEditor } from "~/hooks";
 import { isChildOfFloatingToolbar } from "~/utils/isChildOfFloatingToolbar";
+import { HIDE_FLOATING_TOOLBAR } from "~/commands";
 
 interface FloatingToolbarProps {
     anchorElem: HTMLElement;
@@ -127,6 +128,17 @@ const FloatingToolbar: FC<FloatingToolbarProps> = ({ anchorElem, editor }) => {
                 });
             }),
 
+            editor.registerCommand(
+                HIDE_FLOATING_TOOLBAR,
+                () => {
+                    setTimeout(() => {
+                        setIsVisible(false);
+                    }, 10);
+                    return true;
+                },
+                COMMAND_PRIORITY_LOW
+            ),
+
             editor.registerCommand(
                 SELECTION_CHANGE_COMMAND,
                 () => {
diff --git a/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/FloatingLinkEditorPlugin.tsx b/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/FloatingLinkEditorPlugin.tsx
index 6bb9bc04bc6..f52d53033b2 100644
--- a/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/FloatingLinkEditorPlugin.tsx
+++ b/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/FloatingLinkEditorPlugin.tsx
@@ -78,6 +78,7 @@ export function FloatingLinkEditor({ editor, isVisible, anchorElem }: FloatingLi
         const rootElement = editor.getRootElement();
 
         if (
+            isVisible &&
             selection !== null &&
             nativeSelection !== null &&
             rootElement !== null &&
@@ -107,7 +108,7 @@ export function FloatingLinkEditor({ editor, isVisible, anchorElem }: FloatingLi
         }
 
         return true;
-    }, [anchorElem, editor]);
+    }, [isVisible, anchorElem, editor]);
 
     const removeLink = () => {
         editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
diff --git a/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/useFloatingLinkEditor.tsx b/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/useFloatingLinkEditor.tsx
index cad6ec0723d..73e78ee1272 100644
--- a/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/useFloatingLinkEditor.tsx
+++ b/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/useFloatingLinkEditor.tsx
@@ -1,10 +1,10 @@
-import React, { useCallback, useState, useEffect } from "react";
+import React, { useState, useEffect, useRef } from "react";
 import { createPortal } from "react-dom";
 import { useRichTextEditor } from "~/hooks";
 import { getSelectedNode } from "~/utils/getSelectedNode";
+import { BaseSelection } from "lexical";
 import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from "@webiny/lexical-nodes";
 import { isChildOfLinkEditor } from "~/plugins/FloatingLinkEditorPlugin/isChildOfLinkEditor";
-import debounce from "lodash/debounce";
 import {
     $getSelection,
     $isRangeSelection,
@@ -15,55 +15,81 @@ import {
 } from "lexical";
 import { $findMatchingParent, mergeRegister } from "@lexical/utils";
 import { FloatingLinkEditor } from "./FloatingLinkEditorPlugin";
+import { HIDE_FLOATING_TOOLBAR } from "~/commands";
 
-export function useFloatingLinkEditor(anchorElem: HTMLElement): JSX.Element | null {
-    const { editor } = useRichTextEditor();
-    const [isLink, setIsLink] = useState(false);
+const isLink = (selection: BaseSelection | null) => {
+    if (!$isRangeSelection(selection)) {
+        return;
+    }
 
-    const debounceSetIsLink = useCallback(debounce(setIsLink, 50), []);
+    const node = getSelectedNode(selection);
+    const linkParent = $findMatchingParent(node, $isLinkNode);
+    const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode);
+    const isLinkOrChildOfLink = Boolean($isLinkNode(node) || linkParent);
 
-    const updateToolbar = useCallback(() => {
-        const selection = $getSelection();
-        if (!$isRangeSelection(selection)) {
-            return;
-        }
+    if (!isLinkOrChildOfLink) {
+        return false;
+    }
 
-        const node = getSelectedNode(selection);
-        const linkParent = $findMatchingParent(node, $isLinkNode);
-        const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode);
-        const isLinkOrChildOfLink = Boolean($isLinkNode(node) || linkParent);
+    return linkParent !== null && autoLinkParent == null;
+};
 
-        if (!isLinkOrChildOfLink) {
-            // When hiding the toolbar, we want to hide immediately.
-            setIsLink(false);
-        }
+const isSelectionCollapsed = (selection: BaseSelection | null) => {
+    return $isRangeSelection(selection) && selection.isCollapsed();
+};
+
+const isLinkFocused = (selection: BaseSelection | null) => {
+    return isLink(selection) && isSelectionCollapsed(selection);
+};
+
+const isLinkSelected = (selection: BaseSelection | null) => {
+    return isLink(selection) && !isSelectionCollapsed(selection);
+};
 
-        if (selection.dirty) {
-            // We don't want this menu to open for auto links.
-            if (linkParent != null && autoLinkParent == null) {
-                // When showing the toolbar, we want to debounce it, because sometimes selection gets updated
-                // multiple times, and the `selection.dirty` flag goes from true to false multiple times,
-                // eventually settling on `false`, which we want to set once it has settled.
-                debounceSetIsLink(true);
-            }
+export function useFloatingLinkEditor(anchorElem: HTMLElement): JSX.Element | null {
+    const { editor } = useRichTextEditor();
+    const [isLinkEditorVisible, setShowLinkEditor] = useState(false);
+    const newLinkRef = useRef(false);
+
+    const showLinkEditor = (state: boolean) => {
+        setShowLinkEditor(state);
+        if (!state) {
+            newLinkRef.current = false;
         }
-    }, []);
+    };
 
     useEffect(() => {
         return mergeRegister(
             editor.registerCommand(
                 SELECTION_CHANGE_COMMAND,
                 () => {
-                    updateToolbar();
+                    const selection = $getSelection();
+
+                    if (isLinkFocused(selection)) {
+                        showLinkEditor(true);
+                        return false;
+                    }
+
+                    if (isLinkSelected(selection) && newLinkRef.current) {
+                        return false;
+                    }
+
+                    if (isLinkSelected(selection) && !newLinkRef.current) {
+                        showLinkEditor(false);
+                        return false;
+                    }
+
+                    showLinkEditor(false);
+
                     return false;
                 },
-                COMMAND_PRIORITY_CRITICAL
+                COMMAND_PRIORITY_LOW
             ),
             editor.registerCommand(
                 BLUR_COMMAND,
                 payload => {
                     if (!isChildOfLinkEditor(payload.relatedTarget as HTMLElement)) {
-                        setIsLink(false);
+                        showLinkEditor(false);
                     }
 
                     return false;
@@ -73,16 +99,28 @@ export function useFloatingLinkEditor(anchorElem: HTMLElement): JSX.Element | nu
             editor.registerCommand(
                 TOGGLE_LINK_COMMAND,
                 payload => {
-                    setIsLink(!!payload);
+                    const addLink = !!payload;
+
+                    if (addLink) {
+                        newLinkRef.current = true;
+                        showLinkEditor(true);
+                        editor.dispatchCommand(HIDE_FLOATING_TOOLBAR, {});
+                    } else {
+                        showLinkEditor(false);
+                    }
                     return false;
                 },
                 COMMAND_PRIORITY_CRITICAL
             )
         );
-    }, [editor, updateToolbar]);
+    }, [editor]);
 
     return createPortal(
-        <FloatingLinkEditor isVisible={isLink} editor={editor} anchorElem={anchorElem} />,
+        <FloatingLinkEditor
+            isVisible={isLinkEditorVisible}
+            editor={editor}
+            anchorElem={anchorElem}
+        />,
         anchorElem
     );
 }
diff --git a/packages/lexical-editor/src/utils/setFloatingElemPosition.ts b/packages/lexical-editor/src/utils/setFloatingElemPosition.ts
index 6260d67b88f..a50c9f4b2a8 100644
--- a/packages/lexical-editor/src/utils/setFloatingElemPosition.ts
+++ b/packages/lexical-editor/src/utils/setFloatingElemPosition.ts
@@ -1,46 +1,42 @@
-/**
- * Copyright (c) Meta Platforms, Inc. and affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
 const VERTICAL_GAP = 10;
 const HORIZONTAL_OFFSET = 5;
 
 export function setFloatingElemPosition(
-    targetRect: ClientRect | null,
-    floatingElem: HTMLElement,
+    basePosition: ClientRect | null,
+    elementToPosition: HTMLElement,
     anchorElem: HTMLElement,
     verticalGap: number = VERTICAL_GAP,
     horizontalOffset: number = HORIZONTAL_OFFSET
 ): void {
-    const scrollerElem = anchorElem.parentElement;
+    // A small timeout gives enough time for DOM to update and provides us with correct bounding rect values.
+    setTimeout(() => {
+        const scrollerElem = anchorElem.parentElement;
 
-    if (targetRect === null || !scrollerElem) {
-        floatingElem.style.opacity = "0";
-        floatingElem.style.transform = "translate(-10000px, -10000px)";
-        return;
-    }
+        if (basePosition === null || !scrollerElem) {
+            elementToPosition.style.opacity = "0";
+            elementToPosition.style.transform = "translate(-10000px, -10000px)";
+            return;
+        }
 
-    const floatingElemRect = floatingElem.getBoundingClientRect();
-    const anchorElementRect = anchorElem.getBoundingClientRect();
-    const editorScrollerRect = scrollerElem.getBoundingClientRect();
+        const rectToPosition = elementToPosition.getBoundingClientRect();
+        const anchorElementRect = anchorElem.getBoundingClientRect();
+        const editorScrollerRect = scrollerElem.getBoundingClientRect();
 
-    let top = targetRect.top - floatingElemRect.height - verticalGap;
-    let left = targetRect.left - horizontalOffset;
+        let top = basePosition.top - rectToPosition.height - verticalGap;
+        let left = basePosition.left - horizontalOffset;
 
-    if (top < editorScrollerRect.top) {
-        top += floatingElemRect.height + targetRect.height + verticalGap * 2;
-    }
+        if (top < editorScrollerRect.top) {
+            top += rectToPosition.height + basePosition.height + verticalGap * 2;
+        }
 
-    if (left + floatingElemRect.width > editorScrollerRect.right) {
-        left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset;
-    }
+        if (left + rectToPosition.width > editorScrollerRect.right) {
+            left = editorScrollerRect.right - rectToPosition.width - horizontalOffset;
+        }
 
-    top -= anchorElementRect.top;
-    left -= anchorElementRect.left;
+        top -= anchorElementRect.top;
+        left -= anchorElementRect.left;
 
-    floatingElem.style.opacity = "1";
-    floatingElem.style.transform = `translate(${left}px, ${top}px)`;
+        elementToPosition.style.opacity = "1";
+        elementToPosition.style.transform = `translate(${left}px, ${top}px)`;
+    }, 10);
 }
diff --git a/yarn.lock b/yarn.lock
index 08a8f9dba39..8b5e330dfe3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -17678,7 +17678,6 @@ __metadata:
     "@webiny/react-properties": 0.0.0
     emotion: ^10.0.17
     lexical: ^0.16.1
-    lodash: 4.17.21
     react: 18.2.0
     react-dom: 18.2.0
   languageName: unknown

From 0c7eb4dc6f7d10e97a22cfcfb0904883810dc895 Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Tue, 28 Jan 2025 20:49:17 +0100
Subject: [PATCH 34/52] fix(react-composition): remove scope inheritance

(cherry picked from commit 335ce0796c062ba26596414413a6de2a31590440)
---
 packages/react-composition/src/Compose.tsx |  6 +-
 packages/react-composition/src/Context.tsx | 70 +++++-----------------
 2 files changed, 19 insertions(+), 57 deletions(-)

diff --git a/packages/react-composition/src/Compose.tsx b/packages/react-composition/src/Compose.tsx
index a71529e3bdf..b91a8f7c288 100644
--- a/packages/react-composition/src/Compose.tsx
+++ b/packages/react-composition/src/Compose.tsx
@@ -27,7 +27,11 @@ export const Compose = (props: ComposeProps) => {
         }
 
         const decorators = Array.isArray(props.with) ? props.with : [props.with];
-        return composeComponent(targetFn.original, decorators as Enumerable<ComposeWith>, scope);
+        return composeComponent(
+            targetFn.original,
+            decorators as Enumerable<ComposeWith>,
+            scope[scope.length - 1]
+        );
     }, [props.with]);
 
     return null;
diff --git a/packages/react-composition/src/Context.tsx b/packages/react-composition/src/Context.tsx
index 457c887a31f..f7c08166a48 100644
--- a/packages/react-composition/src/Context.tsx
+++ b/packages/react-composition/src/Context.tsx
@@ -3,7 +3,6 @@ import React, {
     createContext,
     useCallback,
     useContext,
-    useEffect,
     useMemo,
     useState
 } from "react";
@@ -66,7 +65,7 @@ interface CompositionContext {
     composeComponent(
         component: ComponentType<unknown>,
         hocs: Enumerable<ComposeWith>,
-        scope?: string[]
+        scope?: string
     ): void;
 }
 
@@ -80,52 +79,25 @@ interface CompositionProviderProps {
     children: React.ReactNode;
 }
 
-const getCacheKey = (scopes: string[]) => {
-    return scopes.join(";");
-};
-
-/**
- * Scopes are ordered in reverse, to go from child to parent. As we iterate over scopes, we try to find the latest component
- * recipe (a "recipe" is a base component + all decorators registered so far). If none exist, we return an empty recipe.
- */
-const findComponentRecipe = (
-    component: GenericComponent | GenericHook,
-    lookupScopes: string[],
-    components: ComponentScopes
-) => {
-    for (let i = lookupScopes.length; i > 0; i--) {
-        const cacheKey = getCacheKey(lookupScopes.slice(0, i));
-        const scopeMap: ComposedComponents = components.get(cacheKey) || new Map();
-        const recipe = scopeMap.get(component);
-        if (recipe) {
-            return recipe;
-        }
-    }
-
-    return { component: null, hocs: [] };
-};
-
 const composeComponents = (
     components: ComponentScopes,
     decorators: Array<[GenericComponent | GenericHook, Decorator<any>[]]>,
-    scopes: string[] = []
+    scope = "*"
 ) => {
-    const cacheKey = getCacheKey(scopes);
-    const targetComponents: ComposedComponents = components.get(cacheKey) || new Map();
-
+    const scopeMap: ComposedComponents = components.get(scope) || new Map();
     for (const [component, hocs] of decorators) {
-        const recipe = findComponentRecipe(component, scopes, components);
+        const recipe = scopeMap.get(component) || { component: null, hocs: [] };
 
         const newHocs = [...(recipe.hocs || []), ...hocs] as Decorator<
             GenericHook | GenericComponent
         >[];
 
-        targetComponents.set(component, {
+        scopeMap.set(component, {
             component: compose(...[...newHocs].reverse())(component),
             hocs: newHocs
         });
 
-        components.set(cacheKey, targetComponents);
+        components.set(scope, scopeMap);
     }
 
     return components;
@@ -137,8 +109,7 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr
             new Map(),
             decorators.map(tuple => {
                 return [tuple[0].original, tuple[1]];
-            }),
-            ["*"]
+            })
         );
     });
 
@@ -146,20 +117,17 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr
         (
             component: GenericComponent | GenericHook,
             hocs: HigherOrderComponent<any, any>[],
-            scopes: string[] = []
+            scope: string | undefined = "*"
         ) => {
-            const allScopes = ["*", ...scopes];
-
             setComponents(prevComponents => {
-                return composeComponents(new Map(prevComponents), [[component, hocs]], allScopes);
+                return composeComponents(new Map(prevComponents), [[component, hocs]], scope);
             });
 
             // Return a function that will remove the added HOCs.
             return () => {
-                const cacheKey = getCacheKey(allScopes);
                 setComponents(prevComponents => {
                     const components = new Map(prevComponents);
-                    const scopeMap: ComposedComponents = components.get(cacheKey) || new Map();
+                    const scopeMap: ComposedComponents = components.get(scope) || new Map();
                     const recipe = scopeMap.get(component) || {
                         component: null,
                         hocs: []
@@ -173,7 +141,7 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr
                         hocs: newHOCs
                     });
 
-                    components.set(cacheKey, scopeMap);
+                    components.set(scope, scopeMap);
                     return components;
                 });
             };
@@ -183,10 +151,9 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr
 
     const getComponent = useCallback<CompositionContextGetComponentCallable>(
         (Component, scope = []) => {
-            const scopes = ["*", ...scope];
-            for (let i = scopes.length; i > 0; i--) {
-                const cacheKey = getCacheKey(scopes.slice(0, i));
-                const scopeMap: ComposedComponents = components.get(cacheKey) || new Map();
+            const scopesToResolve = ["*", ...scope].reverse();
+            for (const scope of scopesToResolve) {
+                const scopeMap: ComposedComponents = components.get(scope) || new Map();
                 const composedComponent = scopeMap.get(Component);
                 if (composedComponent) {
                     return composedComponent.component;
@@ -207,15 +174,6 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr
         [components, composeComponent]
     );
 
-    useEffect(() => {
-        if (process.env.NODE_ENV !== "production") {
-            // @ts-expect-error This is a developers-only utility.
-            window["debug_printComposedComponents"] = () => {
-                console.log(components);
-            };
-        }
-    }, [components]);
-
     return <CompositionContext.Provider value={context}>{children}</CompositionContext.Provider>;
 };
 

From f700f04287fb4f2128da1a207fb0fa486a2dd773 Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Mon, 17 Feb 2025 19:08:47 +0100
Subject: [PATCH 35/52] fix(app-page-builder): add support for translatable
 items array

---
 .../CollectElementValues.tsx                  | 33 +++++++++++--------
 .../ExtractTranslatableValues.tsx             | 13 +++++---
 .../src/translations/index.ts                 |  4 +++
 3 files changed, 32 insertions(+), 18 deletions(-)

diff --git a/packages/app-page-builder/src/translations/ExtractTranslatableValues/CollectElementValues.tsx b/packages/app-page-builder/src/translations/ExtractTranslatableValues/CollectElementValues.tsx
index 8b1c86ca6a1..45957c92362 100644
--- a/packages/app-page-builder/src/translations/ExtractTranslatableValues/CollectElementValues.tsx
+++ b/packages/app-page-builder/src/translations/ExtractTranslatableValues/CollectElementValues.tsx
@@ -17,12 +17,15 @@ export interface CreateTranslatableItemParams {
     };
 }
 
-export interface CreateTranslatableItem {
-    (params: CreateTranslatableItemParams): Omit<TranslatableItem, "collectionId">;
+export type NewTranslatableItem = Omit<TranslatableItem, "collectionId"> &
+    Partial<Pick<TranslatableItem, "collectionId">>;
+
+export interface CreateTranslatableItems {
+    (params: CreateTranslatableItemParams): NewTranslatableItem[];
 }
 
 export const createElementRendererInputsDecorator = (
-    createTranslatableItem: CreateTranslatableItem
+    createTranslatableItems: CreateTranslatableItems
 ) => {
     return ElementRendererInputs.createDecorator(Original => {
         return function CollectElementValues(props) {
@@ -40,16 +43,20 @@ export const createElementRendererInputsDecorator = (
                         return;
                     }
 
-                    translations.setTranslationItem({
-                        collectionId: `page:${page.id}`,
-                        ...createTranslatableItem({
-                            element,
-                            value,
-                            input: {
-                                name: key,
-                                type: inputs[key].getType()
-                            }
-                        })
+                    const items = createTranslatableItems({
+                        element,
+                        value,
+                        input: {
+                            name: key,
+                            type: inputs[key].getType()
+                        }
+                    });
+
+                    items.forEach(({ collectionId, ...item }) => {
+                        translations.setTranslationItem({
+                            collectionId: collectionId ?? `page:${page.id}`,
+                            ...item
+                        });
                     });
                 });
             }, [element.id, values]);
diff --git a/packages/app-page-builder/src/translations/ExtractTranslatableValues/ExtractTranslatableValues.tsx b/packages/app-page-builder/src/translations/ExtractTranslatableValues/ExtractTranslatableValues.tsx
index e111f592bd1..6956b9694df 100644
--- a/packages/app-page-builder/src/translations/ExtractTranslatableValues/ExtractTranslatableValues.tsx
+++ b/packages/app-page-builder/src/translations/ExtractTranslatableValues/ExtractTranslatableValues.tsx
@@ -4,17 +4,20 @@ import { SaveTranslatableValues } from "~/translations/ExtractTranslatableValues
 import { PageEditorConfig } from "~/pageEditor";
 import {
     createElementRendererInputsDecorator,
-    CreateTranslatableItem
+    CreateTranslatableItems,
+    NewTranslatableItem
 } from "~/translations/ExtractTranslatableValues/CollectElementValues";
 
-interface ExtractTranslatableValuesProps {
-    createTranslatableItem: CreateTranslatableItem;
+export interface ExtractTranslatableValuesProps {
+    createTranslatableItems: CreateTranslatableItems;
 }
 
+export type { NewTranslatableItem, CreateTranslatableItems };
+
 export const ExtractTranslatableValues = ({
-    createTranslatableItem
+    createTranslatableItems
 }: ExtractTranslatableValuesProps) => {
-    const CollectElementValues = createElementRendererInputsDecorator(createTranslatableItem);
+    const CollectElementValues = createElementRendererInputsDecorator(createTranslatableItems);
 
     return (
         <>
diff --git a/packages/app-page-builder/src/translations/index.ts b/packages/app-page-builder/src/translations/index.ts
index 1ad01c48c4e..9bbdd54650b 100644
--- a/packages/app-page-builder/src/translations/index.ts
+++ b/packages/app-page-builder/src/translations/index.ts
@@ -4,5 +4,9 @@ export * from "./translatedCollection/getTranslatedCollection/useTranslatedColle
 export * from "./translatedCollection/saveTranslatedCollection/useSaveTranslatedCollection";
 
 export * from "./ExtractTranslatableValues/ExtractTranslatableValues";
+export type {
+    NewTranslatableItem,
+    CreateTranslatableItems
+} from "./ExtractTranslatableValues/ExtractTranslatableValues";
 export * from "./ListCache";
 export * from "./Loading";

From 7828d72ebff452885923ed5e7f1f2afec8a03309 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bruno=20Zori=C4=87?= <bruno.zoric@gmail.com>
Date: Wed, 19 Feb 2025 20:53:18 +0100
Subject: [PATCH 36/52] fix: record locking (#4543)

---
 .../modelManager/DefaultCmsModelManager.ts    | 15 ++--
 packages/api-headless-cms/src/types/types.ts  |  2 +-
 .../graphql/requestEntryUnlock.test.ts        |  4 +-
 packages/api-record-locking/src/crud/crud.ts  | 32 +++++--
 packages/api-record-locking/src/index.ts      | 13 ++-
 packages/api-record-locking/src/types.ts      | 19 ++--
 .../GetLockRecord/GetLockRecordUseCase.ts     | 11 ++-
 .../GetLockedEntryLockRecordUseCase.ts        |  8 +-
 .../IsEntryLocked/IsEntryLockedUseCase.ts     |  8 +-
 .../KickOutCurrentUserUseCase.ts              |  4 +-
 .../ListAllLockRecordsUseCase.ts              | 14 ++-
 .../ListLockRecordsUseCase.ts                 |  4 +-
 .../LockEntryUseCase/LockEntryUseCase.ts      | 13 +--
 .../UnlockEntryUseCase/UnlockEntryUseCase.ts  | 39 +++++---
 .../UnlockEntryRequestUseCase.ts              | 21 +++--
 .../UpdateEntryLock/UpdateEntryLockUseCase.ts | 15 ++--
 .../api-record-locking/src/useCases/index.ts  | 50 +++++++----
 .../api-record-locking/src/useCases/types.ts  |  6 ++
 .../src/utils/calculateExpiresOn.ts           |  8 +-
 .../src/utils/convertEntryToLockRecord.ts     | 22 ++---
 .../src/utils/getTimeout.ts                   |  8 +-
 .../src/utils/isLockedFactory.ts              |  3 +-
 packages/app-record-locking/package.json      |  2 +
 .../ContentEntryLocker.tsx                    | 51 ++++++-----
 .../LockedRecord/LockedRecordForceUnlock.tsx  |  4 +-
 .../src/components/assets/lock.svg            |  2 +-
 .../RecordLockingPermissions.tsx              | 88 +++++++++++++++++++
 .../components/permissionRenderer/index.tsx   | 22 +++++
 .../src/hooks/usePermission.ts                | 13 ++-
 packages/app-record-locking/src/index.tsx     |  3 +
 packages/app-record-locking/src/types.ts      |  5 ++
 .../app-record-locking/tsconfig.build.json    |  2 +
 packages/app-record-locking/tsconfig.json     |  6 ++
 .../src/WebsocketsContextProvider.tsx         | 67 ++++++++++----
 .../src/domain/WebsocketsConnection.ts        | 11 +++
 packages/app-websockets/src/types.ts          | 16 ++++
 .../pulumi-aws/src/apps/api/ApiGateway.ts     | 10 ++-
 yarn.lock                                     |  2 +
 38 files changed, 458 insertions(+), 165 deletions(-)
 create mode 100644 packages/api-record-locking/src/useCases/types.ts
 create mode 100644 packages/app-record-locking/src/components/permissionRenderer/RecordLockingPermissions.tsx
 create mode 100644 packages/app-record-locking/src/components/permissionRenderer/index.tsx

diff --git a/packages/api-headless-cms/src/modelManager/DefaultCmsModelManager.ts b/packages/api-headless-cms/src/modelManager/DefaultCmsModelManager.ts
index 8a7ac3aafc5..c826de613c3 100644
--- a/packages/api-headless-cms/src/modelManager/DefaultCmsModelManager.ts
+++ b/packages/api-headless-cms/src/modelManager/DefaultCmsModelManager.ts
@@ -1,12 +1,13 @@
-import {
-    CmsModelManager,
-    CmsModel,
+import type {
     CmsContext,
+    CmsDeleteEntryOptions,
     CmsEntryListParams,
+    CmsModel,
+    CmsModelManager,
     CreateCmsEntryInput,
+    CreateCmsEntryOptionsInput,
     UpdateCmsEntryInput,
-    UpdateCmsEntryOptionsInput,
-    CreateCmsEntryOptionsInput
+    UpdateCmsEntryOptionsInput
 } from "~/types";
 import { parseIdentifier } from "@webiny/utils";
 
@@ -23,13 +24,13 @@ export class DefaultCmsModelManager implements CmsModelManager {
         return this._context.cms.createEntry(this.model, data, options);
     }
 
-    public async delete(id: string) {
+    public async delete(id: string, options?: CmsDeleteEntryOptions) {
         const { version } = parseIdentifier(id);
         if (version) {
             return this._context.cms.deleteEntryRevision(this.model, id);
         }
 
-        return this._context.cms.deleteEntry(this.model, id);
+        return this._context.cms.deleteEntry(this.model, id, options);
     }
 
     public async get(id: string) {
diff --git a/packages/api-headless-cms/src/types/types.ts b/packages/api-headless-cms/src/types/types.ts
index 7736379b85f..950033eafa9 100644
--- a/packages/api-headless-cms/src/types/types.ts
+++ b/packages/api-headless-cms/src/types/types.ts
@@ -733,7 +733,7 @@ export interface CmsModelManager<T = CmsEntryValues> {
     /**
      * Delete an entry.
      */
-    delete(id: string): Promise<void>;
+    delete(id: string, options?: CmsDeleteEntryOptions): Promise<void>;
 }
 
 export type ICmsEntryManager<T = GenericRecord> = CmsModelManager<T>;
diff --git a/packages/api-record-locking/__tests__/graphql/requestEntryUnlock.test.ts b/packages/api-record-locking/__tests__/graphql/requestEntryUnlock.test.ts
index eda2e1def03..533ff791cb3 100644
--- a/packages/api-record-locking/__tests__/graphql/requestEntryUnlock.test.ts
+++ b/packages/api-record-locking/__tests__/graphql/requestEntryUnlock.test.ts
@@ -1,4 +1,4 @@
-import { IRecordLockingLockRecordActionType } from "~/types";
+import { RecordLockingLockRecordActionType } from "~/types";
 import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler";
 import { createIdentity } from "~tests/helpers/identity";
 
@@ -67,7 +67,7 @@ describe("request entry unlock", () => {
                             type: "cms#author",
                             actions: [
                                 {
-                                    type: IRecordLockingLockRecordActionType.requested,
+                                    type: RecordLockingLockRecordActionType.requested,
                                     message: null,
                                     createdBy: secondIdentity,
                                     createdOn: expect.toBeDateString()
diff --git a/packages/api-record-locking/src/crud/crud.ts b/packages/api-record-locking/src/crud/crud.ts
index e70598f4d36..e91ce123ef0 100644
--- a/packages/api-record-locking/src/crud/crud.ts
+++ b/packages/api-record-locking/src/crud/crud.ts
@@ -3,7 +3,7 @@ import {
     Context,
     IGetIdentity,
     IGetWebsocketsContextCallable,
-    IHasFullAccessCallable,
+    IHasRecordLockingAccessCallable,
     IRecordLocking,
     IRecordLockingLockRecordValues,
     IRecordLockingModelManager,
@@ -15,7 +15,8 @@ import {
     OnEntryBeforeUnlockTopicParams,
     OnEntryLockErrorTopicParams,
     OnEntryUnlockErrorTopicParams,
-    OnEntryUnlockRequestErrorTopicParams
+    OnEntryUnlockRequestErrorTopicParams,
+    RecordLockingSecurityPermission
 } from "~/types";
 import { RECORD_LOCKING_MODEL_ID } from "./model";
 import { IGetLockRecordUseCaseExecute } from "~/abstractions/IGetLockRecordUseCase";
@@ -29,12 +30,18 @@ import { IListAllLockRecordsUseCaseExecute } from "~/abstractions/IListAllLockRe
 import { IListLockRecordsUseCaseExecute } from "~/abstractions/IListLockRecordsUseCase";
 import { IUpdateEntryLockUseCaseExecute } from "~/abstractions/IUpdateEntryLockUseCase";
 import { IGetLockedEntryLockRecordUseCaseExecute } from "~/abstractions/IGetLockedEntryLockRecordUseCase";
+import { getTimeout as baseGetTimeout } from "~/utils/getTimeout";
 
 interface Params {
     context: Pick<Context, "plugins" | "cms" | "benchmark" | "security" | "websockets">;
+    timeout?: number;
 }
 
-export const createRecordLockingCrud = async ({ context }: Params): Promise<IRecordLocking> => {
+export const createRecordLockingCrud = async (params: Params): Promise<IRecordLocking> => {
+    const { context } = params;
+    const getTimeout = (): number => {
+        return baseGetTimeout(params.timeout);
+    };
     const getModel = async () => {
         const model = await context.cms.getModel(RECORD_LOCKING_MODEL_ID);
         if (model) {
@@ -63,8 +70,15 @@ export const createRecordLockingCrud = async ({ context }: Params): Promise<IRec
         };
     };
 
-    const hasFullAccess: IHasFullAccessCallable = async () => {
-        return await context.security.hasFullAccess();
+    const hasRecordLockingAccess: IHasRecordLockingAccessCallable = async () => {
+        const hasFulLAccess = await context.security.hasFullAccess();
+        if (hasFulLAccess) {
+            return true;
+        }
+        const permission = await context.security.getPermission<RecordLockingSecurityPermission>(
+            "recordLocking"
+        );
+        return permission?.canForceUnlock === "yes";
     };
 
     const onEntryBeforeLock = createTopic<OnEntryBeforeLockTopicParams>(
@@ -114,8 +128,9 @@ export const createRecordLockingCrud = async ({ context }: Params): Promise<IRec
     } = createUseCases({
         getIdentity,
         getManager,
-        hasFullAccess,
-        getWebsockets
+        hasRecordLockingAccess,
+        getWebsockets,
+        getTimeout
     });
 
     const listAllLockRecords: IListAllLockRecordsUseCaseExecute = async params => {
@@ -242,6 +257,7 @@ export const createRecordLockingCrud = async ({ context }: Params): Promise<IRec
         lockEntry,
         updateEntryLock,
         unlockEntry,
-        unlockEntryRequest
+        unlockEntryRequest,
+        getTimeout
     };
 };
diff --git a/packages/api-record-locking/src/index.ts b/packages/api-record-locking/src/index.ts
index 7d94897c264..d2da5b6afeb 100644
--- a/packages/api-record-locking/src/index.ts
+++ b/packages/api-record-locking/src/index.ts
@@ -5,7 +5,11 @@ import { createRecordLockingCrud } from "~/crud/crud";
 import { createLockingModel } from "~/crud/model";
 import { isHeadlessCmsReady } from "@webiny/api-headless-cms";
 
-const createContextPlugin = () => {
+export interface ICreateContextPluginParams {
+    timeout?: number;
+}
+
+const createContextPlugin = (params?: ICreateContextPluginParams) => {
     const plugin = new ContextPlugin<Context>(async context => {
         if (!context.wcp.canUseRecordLocking()) {
             return;
@@ -18,7 +22,8 @@ const createContextPlugin = () => {
         context.plugins.register(createLockingModel());
 
         context.recordLocking = await createRecordLockingCrud({
-            context
+            context,
+            timeout: params?.timeout
         });
 
         const graphQlPlugin = await createGraphQLSchema({ context });
@@ -29,6 +34,6 @@ const createContextPlugin = () => {
     return plugin;
 };
 
-export const createRecordLocking = () => {
-    return [createContextPlugin()];
+export const createRecordLocking = (params?: ICreateContextPluginParams) => {
+    return [createContextPlugin(params)];
 };
diff --git a/packages/api-record-locking/src/types.ts b/packages/api-record-locking/src/types.ts
index 99352f8a343..25d3bb3ccf3 100644
--- a/packages/api-record-locking/src/types.ts
+++ b/packages/api-record-locking/src/types.ts
@@ -13,6 +13,7 @@ import {
     Context as IWebsocketsContext,
     IWebsocketsContextObject
 } from "@webiny/api-websockets/types";
+import { SecurityPermission } from "@webiny/api-security/types";
 
 export { CmsError, CmsEntry };
 
@@ -22,7 +23,7 @@ export type IRecordLockingModelManager = CmsModelManager<IRecordLockingLockRecor
 
 export type IRecordLockingMeta = CmsEntryMeta;
 
-export interface IHasFullAccessCallable {
+export interface IHasRecordLockingAccessCallable {
     (): Promise<boolean>;
 }
 
@@ -39,28 +40,28 @@ export interface IRecordLockingLockRecordValues {
     type: IRecordLockingLockRecordEntryType;
     actions?: IRecordLockingLockRecordAction[];
 }
-export enum IRecordLockingLockRecordActionType {
+export enum RecordLockingLockRecordActionType {
     requested = "requested",
     approved = "approved",
     denied = "denied"
 }
 
 export interface IRecordLockingLockRecordRequestedAction {
-    type: IRecordLockingLockRecordActionType.requested;
+    type: RecordLockingLockRecordActionType.requested;
     message?: string;
     createdOn: Date;
     createdBy: IRecordLockingIdentity;
 }
 
 export interface IRecordLockingLockRecordApprovedAction {
-    type: IRecordLockingLockRecordActionType.approved;
+    type: RecordLockingLockRecordActionType.approved;
     message?: string;
     createdOn: Date;
     createdBy: IRecordLockingIdentity;
 }
 
 export interface IRecordLockingLockRecordDeniedAction {
-    type: IRecordLockingLockRecordActionType.denied;
+    type: RecordLockingLockRecordActionType.denied;
     message?: string;
     createdOn: Date;
     createdBy: IRecordLockingIdentity;
@@ -198,6 +199,10 @@ export interface OnEntryUnlockRequestErrorTopicParams {
 }
 
 export interface IRecordLocking {
+    /**
+     * In milliseconds.
+     */
+    getTimeout: () => number;
     onEntryBeforeLock: Topic<OnEntryBeforeLockTopicParams>;
     onEntryAfterLock: Topic<OnEntryAfterLockTopicParams>;
     onEntryLockError: Topic<OnEntryLockErrorTopicParams>;
@@ -235,3 +240,7 @@ export interface IRecordLocking {
 export interface Context extends CmsContext, IWebsocketsContext {
     recordLocking: IRecordLocking;
 }
+
+export interface RecordLockingSecurityPermission extends SecurityPermission {
+    canForceUnlock?: string;
+}
diff --git a/packages/api-record-locking/src/useCases/GetLockRecord/GetLockRecordUseCase.ts b/packages/api-record-locking/src/useCases/GetLockRecord/GetLockRecordUseCase.ts
index bf4a1fcc8b4..9ebe0abe37e 100644
--- a/packages/api-record-locking/src/useCases/GetLockRecord/GetLockRecordUseCase.ts
+++ b/packages/api-record-locking/src/useCases/GetLockRecord/GetLockRecordUseCase.ts
@@ -1,22 +1,25 @@
-import {
+import type {
     IGetLockRecordUseCase,
     IGetLockRecordUseCaseExecuteParams
 } from "~/abstractions/IGetLockRecordUseCase";
-import { IRecordLockingModelManager, IRecordLockingLockRecord } from "~/types";
+import type { IRecordLockingLockRecord, IRecordLockingModelManager } from "~/types";
 import { NotFoundError } from "@webiny/handler-graphql";
-import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord";
 import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId";
 import { createIdentifier } from "@webiny/utils";
+import type { ConvertEntryToLockRecordCb } from "~/useCases/types";
 
 export interface IGetLockRecordUseCaseParams {
     getManager(): Promise<IRecordLockingModelManager>;
+    convert: ConvertEntryToLockRecordCb;
 }
 
 export class GetLockRecordUseCase implements IGetLockRecordUseCase {
     private readonly getManager: IGetLockRecordUseCaseParams["getManager"];
+    private readonly convert: ConvertEntryToLockRecordCb;
 
     public constructor(params: IGetLockRecordUseCaseParams) {
         this.getManager = params.getManager;
+        this.convert = params.convert;
     }
 
     public async execute(
@@ -30,7 +33,7 @@ export class GetLockRecordUseCase implements IGetLockRecordUseCase {
         try {
             const manager = await this.getManager();
             const result = await manager.get(id);
-            return convertEntryToLockRecord(result);
+            return this.convert(result);
         } catch (ex) {
             if (ex instanceof NotFoundError) {
                 return null;
diff --git a/packages/api-record-locking/src/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts b/packages/api-record-locking/src/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts
index 28943401a92..90a54a078b8 100644
--- a/packages/api-record-locking/src/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts
+++ b/packages/api-record-locking/src/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts
@@ -1,10 +1,10 @@
-import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase";
-import { IGetIdentity, IRecordLockingLockRecord } from "~/types";
-import {
+import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase";
+import type { IGetIdentity, IRecordLockingLockRecord } from "~/types";
+import type {
     IGetLockedEntryLockRecordUseCase,
     IGetLockedEntryLockRecordUseCaseExecuteParams
 } from "~/abstractions/IGetLockedEntryLockRecordUseCase";
-import { IIsLocked } from "~/utils/isLockedFactory";
+import type { IIsLocked } from "~/utils/isLockedFactory";
 
 export interface IGetLockedEntryLockRecordUseCaseParams {
     getLockRecordUseCase: IGetLockRecordUseCase;
diff --git a/packages/api-record-locking/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts b/packages/api-record-locking/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts
index be2563be9ff..24c00a4797f 100644
--- a/packages/api-record-locking/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts
+++ b/packages/api-record-locking/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts
@@ -1,11 +1,11 @@
-import {
+import type {
     IIsEntryLockedUseCase,
     IIsEntryLockedUseCaseExecuteParams
 } from "~/abstractions/IIsEntryLocked";
-import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase";
+import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase";
 import { NotFoundError } from "@webiny/handler-graphql";
-import { IIsLocked } from "~/utils/isLockedFactory";
-import { IGetIdentity } from "~/types";
+import type { IIsLocked } from "~/utils/isLockedFactory";
+import type { IGetIdentity } from "~/types";
 
 export interface IIsEntryLockedParams {
     getLockRecordUseCase: IGetLockRecordUseCase;
diff --git a/packages/api-record-locking/src/useCases/KickOutCurrentUser/KickOutCurrentUserUseCase.ts b/packages/api-record-locking/src/useCases/KickOutCurrentUser/KickOutCurrentUserUseCase.ts
index bd3f38b7f13..eeb099f26ee 100644
--- a/packages/api-record-locking/src/useCases/KickOutCurrentUser/KickOutCurrentUserUseCase.ts
+++ b/packages/api-record-locking/src/useCases/KickOutCurrentUser/KickOutCurrentUserUseCase.ts
@@ -1,8 +1,8 @@
-import {
+import type {
     IKickOutCurrentUserUseCase,
     IKickOutCurrentUserUseCaseExecuteParams
 } from "~/abstractions/IKickOutCurrentUserUseCase";
-import { IGetIdentity, IGetWebsocketsContextCallable } from "~/types";
+import type { IGetIdentity, IGetWebsocketsContextCallable } from "~/types";
 import { parseIdentifier } from "@webiny/utils";
 
 export interface IKickOutCurrentUserUseCaseParams {
diff --git a/packages/api-record-locking/src/useCases/ListAllLockRecordsUseCase/ListAllLockRecordsUseCase.ts b/packages/api-record-locking/src/useCases/ListAllLockRecordsUseCase/ListAllLockRecordsUseCase.ts
index d85eee3b5d9..b6067eeb3c5 100644
--- a/packages/api-record-locking/src/useCases/ListAllLockRecordsUseCase/ListAllLockRecordsUseCase.ts
+++ b/packages/api-record-locking/src/useCases/ListAllLockRecordsUseCase/ListAllLockRecordsUseCase.ts
@@ -1,20 +1,24 @@
-import {
+import type {
     IListAllLockRecordsUseCase,
     IListAllLockRecordsUseCaseExecuteParams,
     IListAllLockRecordsUseCaseExecuteResponse
 } from "~/abstractions/IListAllLockRecordsUseCase";
-import { IRecordLockingModelManager } from "~/types";
-import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord";
+import type { IRecordLockingModelManager } from "~/types";
 import { convertWhereCondition } from "~/utils/convertWhereCondition";
+import type { ConvertEntryToLockRecordCb } from "~/useCases/types";
 
 export interface IListAllLockRecordsUseCaseParams {
     getManager(): Promise<IRecordLockingModelManager>;
+    convert: ConvertEntryToLockRecordCb;
 }
 
 export class ListAllLockRecordsUseCase implements IListAllLockRecordsUseCase {
     private readonly getManager: () => Promise<IRecordLockingModelManager>;
+    private readonly convert: ConvertEntryToLockRecordCb;
+
     public constructor(params: IListAllLockRecordsUseCaseParams) {
         this.getManager = params.getManager;
+        this.convert = params.convert;
     }
     public async execute(
         input: IListAllLockRecordsUseCaseExecuteParams
@@ -28,7 +32,9 @@ export class ListAllLockRecordsUseCase implements IListAllLockRecordsUseCase {
 
             const [items, meta] = await manager.listLatest(params);
             return {
-                items: items.map(convertEntryToLockRecord),
+                items: items.map(item => {
+                    return this.convert(item);
+                }),
                 meta
             };
         } catch (ex) {
diff --git a/packages/api-record-locking/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts b/packages/api-record-locking/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts
index 72ff2df28bd..65147ad6b7f 100644
--- a/packages/api-record-locking/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts
+++ b/packages/api-record-locking/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts
@@ -1,9 +1,9 @@
-import {
+import type {
     IListLockRecordsUseCase,
     IListLockRecordsUseCaseExecuteParams,
     IListLockRecordsUseCaseExecuteResponse
 } from "~/abstractions/IListLockRecordsUseCase";
-import { IGetIdentity } from "~/types";
+import type { IGetIdentity } from "~/types";
 
 export interface IListLockRecordsUseCaseParams {
     listAllLockRecordsUseCase: IListLockRecordsUseCase;
diff --git a/packages/api-record-locking/src/useCases/LockEntryUseCase/LockEntryUseCase.ts b/packages/api-record-locking/src/useCases/LockEntryUseCase/LockEntryUseCase.ts
index 75f44685357..869eccc2b92 100644
--- a/packages/api-record-locking/src/useCases/LockEntryUseCase/LockEntryUseCase.ts
+++ b/packages/api-record-locking/src/useCases/LockEntryUseCase/LockEntryUseCase.ts
@@ -1,30 +1,33 @@
 import WebinyError from "@webiny/error";
-import {
+import type {
     ILockEntryUseCase,
     ILockEntryUseCaseExecuteParams
 } from "~/abstractions/ILockEntryUseCase";
-import {
+import type {
     IRecordLockingLockRecord,
     IRecordLockingLockRecordValues,
     IRecordLockingModelManager
 } from "~/types";
-import { IIsEntryLockedUseCase } from "~/abstractions/IIsEntryLocked";
-import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord";
+import type { IIsEntryLockedUseCase } from "~/abstractions/IIsEntryLocked";
 import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId";
 import { NotFoundError } from "@webiny/handler-graphql";
+import type { ConvertEntryToLockRecordCb } from "~/useCases/types";
 
 export interface ILockEntryUseCaseParams {
     isEntryLockedUseCase: IIsEntryLockedUseCase;
     getManager(): Promise<IRecordLockingModelManager>;
+    convert: ConvertEntryToLockRecordCb;
 }
 
 export class LockEntryUseCase implements ILockEntryUseCase {
     private readonly isEntryLockedUseCase: IIsEntryLockedUseCase;
     private readonly getManager: () => Promise<IRecordLockingModelManager>;
+    private readonly convert: ConvertEntryToLockRecordCb;
 
     public constructor(params: ILockEntryUseCaseParams) {
         this.isEntryLockedUseCase = params.isEntryLockedUseCase;
         this.getManager = params.getManager;
+        this.convert = params.convert;
     }
 
     public async execute(
@@ -54,7 +57,7 @@ export class LockEntryUseCase implements ILockEntryUseCase {
                 type: params.type,
                 actions: []
             });
-            return convertEntryToLockRecord(entry);
+            return this.convert(entry);
         } catch (ex) {
             throw new WebinyError(
                 `Could not lock entry: ${ex.message}`,
diff --git a/packages/api-record-locking/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts b/packages/api-record-locking/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts
index 8ea64617b37..4c0f7d111d2 100644
--- a/packages/api-record-locking/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts
+++ b/packages/api-record-locking/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts
@@ -1,26 +1,27 @@
 import WebinyError from "@webiny/error";
-import {
+import type {
     IUnlockEntryUseCase,
     IUnlockEntryUseCaseExecuteParams
 } from "~/abstractions/IUnlockEntryUseCase";
-import {
+import type {
     IGetIdentity,
-    IHasFullAccessCallable,
+    IHasRecordLockingAccessCallable,
     IRecordLockingLockRecord,
     IRecordLockingModelManager
 } from "~/types";
 import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId";
-import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase";
+import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase";
 import { validateSameIdentity } from "~/utils/validateSameIdentity";
 import { NotAuthorizedError } from "@webiny/api-security";
-import { IKickOutCurrentUserUseCase } from "~/abstractions/IKickOutCurrentUserUseCase";
+import type { IKickOutCurrentUserUseCase } from "~/abstractions/IKickOutCurrentUserUseCase";
+import { NotFoundError } from "@webiny/handler-graphql";
 
 export interface IUnlockEntryUseCaseParams {
     readonly getLockRecordUseCase: IGetLockRecordUseCase;
     readonly kickOutCurrentUserUseCase: IKickOutCurrentUserUseCase;
     getManager(): Promise<IRecordLockingModelManager>;
     getIdentity: IGetIdentity;
-    hasFullAccess: IHasFullAccessCallable;
+    hasRecordLockingAccess: IHasRecordLockingAccessCallable;
 }
 
 export class UnlockEntryUseCase implements IUnlockEntryUseCase {
@@ -28,14 +29,14 @@ export class UnlockEntryUseCase implements IUnlockEntryUseCase {
     private readonly kickOutCurrentUserUseCase: IKickOutCurrentUserUseCase;
     private readonly getManager: () => Promise<IRecordLockingModelManager>;
     private readonly getIdentity: IGetIdentity;
-    private readonly hasFullAccess: IHasFullAccessCallable;
+    private readonly hasRecordLockingAccess: IHasRecordLockingAccessCallable;
 
     public constructor(params: IUnlockEntryUseCaseParams) {
         this.getLockRecordUseCase = params.getLockRecordUseCase;
         this.kickOutCurrentUserUseCase = params.kickOutCurrentUserUseCase;
         this.getManager = params.getManager;
         this.getIdentity = params.getIdentity;
-        this.hasFullAccess = params.hasFullAccess;
+        this.hasRecordLockingAccess = params.hasRecordLockingAccess;
     }
 
     public async execute(
@@ -43,6 +44,19 @@ export class UnlockEntryUseCase implements IUnlockEntryUseCase {
     ): Promise<IRecordLockingLockRecord> {
         const record = await this.getLockRecordUseCase.execute(params);
         if (!record) {
+            try {
+                const manager = await this.getManager();
+                await manager.delete(createLockRecordDatabaseId(params.id), {
+                    force: true,
+                    permanently: true
+                });
+            } catch (ex) {
+                if (ex instanceof NotFoundError === false) {
+                    console.log("Could not forcefully delete lock record.");
+                    console.error(ex);
+                }
+            }
+
             throw new WebinyError("Lock Record not found.", "LOCK_RECORD_NOT_FOUND", {
                 ...params
             });
@@ -64,8 +78,8 @@ export class UnlockEntryUseCase implements IUnlockEntryUseCase {
             if (!params.force) {
                 throw ex;
             }
-            const hasFullAccess = await this.hasFullAccess();
-            if (ex instanceof NotAuthorizedError === false || !hasFullAccess) {
+            const hasAccess = await this.hasRecordLockingAccess();
+            if (ex instanceof NotAuthorizedError === false || !hasAccess) {
                 throw ex;
             }
 
@@ -74,7 +88,10 @@ export class UnlockEntryUseCase implements IUnlockEntryUseCase {
 
         try {
             const manager = await this.getManager();
-            await manager.delete(createLockRecordDatabaseId(params.id));
+            await manager.delete(createLockRecordDatabaseId(params.id), {
+                force: true,
+                permanently: true
+            });
 
             if (!kickOutCurrentUser) {
                 return record;
diff --git a/packages/api-record-locking/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts b/packages/api-record-locking/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts
index 05044ba4a56..541a137c335 100644
--- a/packages/api-record-locking/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts
+++ b/packages/api-record-locking/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts
@@ -1,34 +1,33 @@
 import WebinyError from "@webiny/error";
-import {
+import type {
     IUnlockEntryRequestUseCase,
     IUnlockEntryRequestUseCaseExecuteParams
 } from "~/abstractions/IUnlockEntryRequestUseCase";
-import {
-    IGetIdentity,
-    IRecordLockingLockRecord,
-    IRecordLockingLockRecordActionType,
-    IRecordLockingModelManager
-} from "~/types";
-import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase";
+import type { IGetIdentity, IRecordLockingLockRecord, IRecordLockingModelManager } from "~/types";
+import { RecordLockingLockRecordActionType } from "~/types";
+import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase";
 import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId";
 import { createIdentifier } from "@webiny/utils";
-import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord";
+import type { ConvertEntryToLockRecordCb } from "~/useCases/types";
 
 export interface IUnlockEntryRequestUseCaseParams {
     getLockRecordUseCase: IGetLockRecordUseCase;
     getManager: () => Promise<IRecordLockingModelManager>;
     getIdentity: IGetIdentity;
+    convert: ConvertEntryToLockRecordCb;
 }
 
 export class UnlockEntryRequestUseCase implements IUnlockEntryRequestUseCase {
     private readonly getLockRecordUseCase: IGetLockRecordUseCase;
     private readonly getManager: () => Promise<IRecordLockingModelManager>;
     private readonly getIdentity: IGetIdentity;
+    private readonly convert: ConvertEntryToLockRecordCb;
 
     public constructor(params: IUnlockEntryRequestUseCaseParams) {
         this.getLockRecordUseCase = params.getLockRecordUseCase;
         this.getManager = params.getManager;
         this.getIdentity = params.getIdentity;
+        this.convert = params.convert;
     }
 
     public async execute(
@@ -68,7 +67,7 @@ export class UnlockEntryRequestUseCase implements IUnlockEntryRequestUseCase {
         }
 
         record.addAction({
-            type: IRecordLockingLockRecordActionType.requested,
+            type: RecordLockingLockRecordActionType.requested,
             createdOn: new Date(),
             createdBy: this.getIdentity()
         });
@@ -82,7 +81,7 @@ export class UnlockEntryRequestUseCase implements IUnlockEntryRequestUseCase {
                 version: 1
             });
             const result = await manager.update(id, record.toObject());
-            return convertEntryToLockRecord(result);
+            return this.convert(result);
         } catch (ex) {
             throw new WebinyError(
                 "Could not update record with a unlock request.",
diff --git a/packages/api-record-locking/src/useCases/UpdateEntryLock/UpdateEntryLockUseCase.ts b/packages/api-record-locking/src/useCases/UpdateEntryLock/UpdateEntryLockUseCase.ts
index 052f37b4b1a..7e858ed2641 100644
--- a/packages/api-record-locking/src/useCases/UpdateEntryLock/UpdateEntryLockUseCase.ts
+++ b/packages/api-record-locking/src/useCases/UpdateEntryLock/UpdateEntryLockUseCase.ts
@@ -1,21 +1,22 @@
-import {
+import type {
     IUpdateEntryLockUseCase,
     IUpdateEntryLockUseCaseExecuteParams
 } from "~/abstractions/IUpdateEntryLockUseCase";
-import { IGetIdentity, IRecordLockingLockRecord, IRecordLockingModelManager } from "~/types";
-import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase";
+import type { IGetIdentity, IRecordLockingLockRecord, IRecordLockingModelManager } from "~/types";
+import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase";
 import { WebinyError } from "@webiny/error";
-import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord";
 import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId";
 import { createIdentifier } from "@webiny/utils";
 import { validateSameIdentity } from "~/utils/validateSameIdentity";
-import { ILockEntryUseCase } from "~/abstractions/ILockEntryUseCase";
+import type { ILockEntryUseCase } from "~/abstractions/ILockEntryUseCase";
+import type { ConvertEntryToLockRecordCb } from "~/useCases/types";
 
 export interface IUpdateEntryLockUseCaseParams {
     readonly getLockRecordUseCase: IGetLockRecordUseCase;
     readonly lockEntryUseCase: ILockEntryUseCase;
     getManager(): Promise<IRecordLockingModelManager>;
     getIdentity: IGetIdentity;
+    convert: ConvertEntryToLockRecordCb;
 }
 
 export class UpdateEntryLockUseCase implements IUpdateEntryLockUseCase {
@@ -23,12 +24,14 @@ export class UpdateEntryLockUseCase implements IUpdateEntryLockUseCase {
     private readonly lockEntryUseCase: ILockEntryUseCase;
     private readonly getManager: () => Promise<IRecordLockingModelManager>;
     private readonly getIdentity: IGetIdentity;
+    private readonly convert: ConvertEntryToLockRecordCb;
 
     public constructor(params: IUpdateEntryLockUseCaseParams) {
         this.getLockRecordUseCase = params.getLockRecordUseCase;
         this.lockEntryUseCase = params.lockEntryUseCase;
         this.getManager = params.getManager;
         this.getIdentity = params.getIdentity;
+        this.convert = params.convert;
     }
 
     public async execute(
@@ -55,7 +58,7 @@ export class UpdateEntryLockUseCase implements IUpdateEntryLockUseCase {
             const result = await manager.update(id, {
                 savedOn: new Date().toISOString()
             });
-            return convertEntryToLockRecord(result);
+            return this.convert(result);
         } catch (ex) {
             throw new WebinyError(
                 `Could not update lock entry: ${ex.message}`,
diff --git a/packages/api-record-locking/src/useCases/index.ts b/packages/api-record-locking/src/useCases/index.ts
index 2198c85ca9e..86d4e614d70 100644
--- a/packages/api-record-locking/src/useCases/index.ts
+++ b/packages/api-record-locking/src/useCases/index.ts
@@ -1,7 +1,7 @@
-import {
+import type {
     IGetIdentity,
     IGetWebsocketsContextCallable,
-    IHasFullAccessCallable,
+    IHasRecordLockingAccessCallable,
     IRecordLockingModelManager
 } from "~/types";
 import { GetLockRecordUseCase } from "./GetLockRecord/GetLockRecordUseCase";
@@ -13,23 +13,25 @@ import { ListAllLockRecordsUseCase } from "./ListAllLockRecordsUseCase/ListAllLo
 import { ListLockRecordsUseCase } from "./ListLockRecordsUseCase/ListLockRecordsUseCase";
 import { isLockedFactory } from "~/utils/isLockedFactory";
 import { UpdateEntryLockUseCase } from "~/useCases/UpdateEntryLock/UpdateEntryLockUseCase";
-import { getTimeout } from "~/utils/getTimeout";
 import { KickOutCurrentUserUseCase } from "./KickOutCurrentUser/KickOutCurrentUserUseCase";
 import { GetLockedEntryLockRecordUseCase } from "~/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase";
-import { IListAllLockRecordsUseCase } from "~/abstractions/IListAllLockRecordsUseCase";
-import { IListLockRecordsUseCase } from "~/abstractions/IListLockRecordsUseCase";
-import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase";
-import { IIsEntryLockedUseCase } from "~/abstractions/IIsEntryLocked";
-import { IGetLockedEntryLockRecordUseCase } from "~/abstractions/IGetLockedEntryLockRecordUseCase";
-import { ILockEntryUseCase } from "~/abstractions/ILockEntryUseCase";
-import { IUpdateEntryLockUseCase } from "~/abstractions/IUpdateEntryLockUseCase";
-import { IUnlockEntryUseCase } from "~/abstractions/IUnlockEntryUseCase";
-import { IUnlockEntryRequestUseCase } from "~/abstractions/IUnlockEntryRequestUseCase";
+import type { IListAllLockRecordsUseCase } from "~/abstractions/IListAllLockRecordsUseCase";
+import type { IListLockRecordsUseCase } from "~/abstractions/IListLockRecordsUseCase";
+import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase";
+import type { IIsEntryLockedUseCase } from "~/abstractions/IIsEntryLocked";
+import type { IGetLockedEntryLockRecordUseCase } from "~/abstractions/IGetLockedEntryLockRecordUseCase";
+import type { ILockEntryUseCase } from "~/abstractions/ILockEntryUseCase";
+import type { IUpdateEntryLockUseCase } from "~/abstractions/IUpdateEntryLockUseCase";
+import type { IUnlockEntryUseCase } from "~/abstractions/IUnlockEntryUseCase";
+import type { IUnlockEntryRequestUseCase } from "~/abstractions/IUnlockEntryRequestUseCase";
+import { convertEntryToLockRecord as baseConvertEntryToLockRecord } from "~/utils/convertEntryToLockRecord";
+import { ConvertEntryToLockRecordCb } from "~/useCases/types";
 
 export interface ICreateUseCasesParams {
+    getTimeout: () => number;
     getIdentity: IGetIdentity;
     getManager(): Promise<IRecordLockingModelManager>;
-    hasFullAccess: IHasFullAccessCallable;
+    hasRecordLockingAccess: IHasRecordLockingAccessCallable;
     getWebsockets: IGetWebsocketsContextCallable;
 }
 
@@ -46,11 +48,17 @@ export interface ICreateUseCasesResponse {
 }
 
 export const createUseCases = (params: ICreateUseCasesParams): ICreateUseCasesResponse => {
+    const { getTimeout } = params;
     const timeout = getTimeout();
     const isLocked = isLockedFactory(timeout);
 
+    const convertEntryToLockRecord: ConvertEntryToLockRecordCb = entry => {
+        return baseConvertEntryToLockRecord(entry, timeout);
+    };
+
     const listAllLockRecordsUseCase = new ListAllLockRecordsUseCase({
-        getManager: params.getManager
+        getManager: params.getManager,
+        convert: convertEntryToLockRecord
     });
 
     const listLockRecordsUseCase = new ListLockRecordsUseCase({
@@ -60,7 +68,8 @@ export const createUseCases = (params: ICreateUseCasesParams): ICreateUseCasesRe
     });
 
     const getLockRecordUseCase = new GetLockRecordUseCase({
-        getManager: params.getManager
+        getManager: params.getManager,
+        convert: convertEntryToLockRecord
     });
 
     const isEntryLockedUseCase = new IsEntryLockedUseCase({
@@ -77,14 +86,16 @@ export const createUseCases = (params: ICreateUseCasesParams): ICreateUseCasesRe
 
     const lockEntryUseCase = new LockEntryUseCase({
         isEntryLockedUseCase,
-        getManager: params.getManager
+        getManager: params.getManager,
+        convert: convertEntryToLockRecord
     });
 
     const updateEntryLockUseCase = new UpdateEntryLockUseCase({
         getLockRecordUseCase,
         lockEntryUseCase,
         getManager: params.getManager,
-        getIdentity: params.getIdentity
+        getIdentity: params.getIdentity,
+        convert: convertEntryToLockRecord
     });
 
     const kickOutCurrentUserUseCase = new KickOutCurrentUserUseCase({
@@ -97,13 +108,14 @@ export const createUseCases = (params: ICreateUseCasesParams): ICreateUseCasesRe
         kickOutCurrentUserUseCase,
         getManager: params.getManager,
         getIdentity: params.getIdentity,
-        hasFullAccess: params.hasFullAccess
+        hasRecordLockingAccess: params.hasRecordLockingAccess
     });
 
     const unlockEntryRequestUseCase = new UnlockEntryRequestUseCase({
         getLockRecordUseCase,
         getIdentity: params.getIdentity,
-        getManager: params.getManager
+        getManager: params.getManager,
+        convert: convertEntryToLockRecord
     });
 
     return {
diff --git a/packages/api-record-locking/src/useCases/types.ts b/packages/api-record-locking/src/useCases/types.ts
new file mode 100644
index 00000000000..4e25f994fdc
--- /dev/null
+++ b/packages/api-record-locking/src/useCases/types.ts
@@ -0,0 +1,6 @@
+import type { CmsEntry } from "@webiny/api-headless-cms/types";
+import type { IRecordLockingLockRecord, IRecordLockingLockRecordValues } from "~/types";
+
+export interface ConvertEntryToLockRecordCb {
+    (entry: CmsEntry<IRecordLockingLockRecordValues>): IRecordLockingLockRecord;
+}
diff --git a/packages/api-record-locking/src/utils/calculateExpiresOn.ts b/packages/api-record-locking/src/utils/calculateExpiresOn.ts
index 0897986577b..11c5d60330c 100644
--- a/packages/api-record-locking/src/utils/calculateExpiresOn.ts
+++ b/packages/api-record-locking/src/utils/calculateExpiresOn.ts
@@ -1,9 +1,9 @@
 import { IHeadlessCmsLockRecordParams } from "./convertEntryToLockRecord";
-import { getTimeout } from "./getTimeout";
-
-export const calculateExpiresOn = (input: Pick<IHeadlessCmsLockRecordParams, "savedOn">): Date => {
-    const timeout = getTimeout();
 
+export const calculateExpiresOn = (
+    input: Pick<IHeadlessCmsLockRecordParams, "savedOn">,
+    timeout: number
+): Date => {
     const savedOn = new Date(input.savedOn);
 
     return new Date(savedOn.getTime() + timeout);
diff --git a/packages/api-record-locking/src/utils/convertEntryToLockRecord.ts b/packages/api-record-locking/src/utils/convertEntryToLockRecord.ts
index d5cc5e746e3..c20a5d3a6d6 100644
--- a/packages/api-record-locking/src/utils/convertEntryToLockRecord.ts
+++ b/packages/api-record-locking/src/utils/convertEntryToLockRecord.ts
@@ -1,8 +1,8 @@
-import { CmsEntry, IRecordLockingIdentity } from "~/types";
-import {
+import type {
+    CmsEntry,
+    IRecordLockingIdentity,
     IRecordLockingLockRecord,
     IRecordLockingLockRecordAction,
-    IRecordLockingLockRecordActionType,
     IRecordLockingLockRecordApprovedAction,
     IRecordLockingLockRecordDeniedAction,
     IRecordLockingLockRecordEntryType,
@@ -10,13 +10,15 @@ import {
     IRecordLockingLockRecordRequestedAction,
     IRecordLockingLockRecordValues
 } from "~/types";
+import { RecordLockingLockRecordActionType } from "~/types";
 import { removeLockRecordDatabasePrefix } from "~/utils/lockRecordDatabaseId";
 import { calculateExpiresOn } from "~/utils/calculateExpiresOn";
 
 export const convertEntryToLockRecord = (
-    entry: CmsEntry<IRecordLockingLockRecordValues>
+    entry: CmsEntry<IRecordLockingLockRecordValues>,
+    timeout: number
 ): IRecordLockingLockRecord => {
-    return new HeadlessCmsLockRecord(entry);
+    return new HeadlessCmsLockRecord(entry, timeout);
 };
 
 export type IHeadlessCmsLockRecordParams = Pick<
@@ -66,14 +68,14 @@ export class HeadlessCmsLockRecord implements IRecordLockingLockRecord {
         return this._actions;
     }
 
-    public constructor(input: IHeadlessCmsLockRecordParams) {
+    public constructor(input: IHeadlessCmsLockRecordParams, timeout: number) {
         this._id = removeLockRecordDatabasePrefix(input.entryId);
         this._targetId = input.values.targetId;
         this._type = input.values.type;
         this._lockedBy = input.createdBy;
         this._lockedOn = new Date(input.createdOn);
         this._updatedOn = new Date(input.savedOn);
-        this._expiresOn = calculateExpiresOn(input);
+        this._expiresOn = calculateExpiresOn(input, timeout);
         this._actions = input.values.actions;
     }
 
@@ -103,7 +105,7 @@ export class HeadlessCmsLockRecord implements IRecordLockingLockRecord {
         }
         return this._actions.find(
             (action): action is IRecordLockingLockRecordRequestedAction =>
-                action.type === IRecordLockingLockRecordActionType.requested
+                action.type === RecordLockingLockRecordActionType.requested
         );
     }
 
@@ -113,7 +115,7 @@ export class HeadlessCmsLockRecord implements IRecordLockingLockRecord {
         }
         return this._actions.find(
             (action): action is IRecordLockingLockRecordApprovedAction =>
-                action.type === IRecordLockingLockRecordActionType.approved
+                action.type === RecordLockingLockRecordActionType.approved
         );
     }
 
@@ -123,7 +125,7 @@ export class HeadlessCmsLockRecord implements IRecordLockingLockRecord {
         }
         return this._actions.find(
             (action): action is IRecordLockingLockRecordDeniedAction =>
-                action.type === IRecordLockingLockRecordActionType.denied
+                action.type === RecordLockingLockRecordActionType.denied
         );
     }
 }
diff --git a/packages/api-record-locking/src/utils/getTimeout.ts b/packages/api-record-locking/src/utils/getTimeout.ts
index bd18862a6af..8792a48ca6d 100644
--- a/packages/api-record-locking/src/utils/getTimeout.ts
+++ b/packages/api-record-locking/src/utils/getTimeout.ts
@@ -1,8 +1,12 @@
 const defaultTimeoutInSeconds = 1800;
 /**
- * In milliseconds.
+ * Input is in seconds.
+ * Output is milliseconds.
  */
-export const getTimeout = () => {
+export const getTimeout = (input: number | undefined) => {
+    if (input && input > 0) {
+        return input * 1000;
+    }
     const userDefined = process.env.WEBINY_RECORD_LOCK_TIMEOUT
         ? parseInt(process.env.WEBINY_RECORD_LOCK_TIMEOUT)
         : undefined;
diff --git a/packages/api-record-locking/src/utils/isLockedFactory.ts b/packages/api-record-locking/src/utils/isLockedFactory.ts
index 9d3cc7ddc4c..2acc8343330 100644
--- a/packages/api-record-locking/src/utils/isLockedFactory.ts
+++ b/packages/api-record-locking/src/utils/isLockedFactory.ts
@@ -4,8 +4,7 @@ export interface IIsLocked {
     (record?: Pick<IRecordLockingLockRecord, "lockedOn"> | null): boolean;
 }
 
-export const isLockedFactory = (timeoutInput: number): IIsLocked => {
-    const timeout = timeoutInput * 1000;
+export const isLockedFactory = (timeout: number): IIsLocked => {
     return record => {
         if (!record || record.lockedOn instanceof Date === false) {
             return false;
diff --git a/packages/app-record-locking/package.json b/packages/app-record-locking/package.json
index f34c50cce96..ea8945071f2 100644
--- a/packages/app-record-locking/package.json
+++ b/packages/app-record-locking/package.json
@@ -24,6 +24,8 @@
     "@webiny/app-wcp": "0.0.0",
     "@webiny/app-websockets": "0.0.0",
     "@webiny/error": "0.0.0",
+    "@webiny/form": "0.0.0",
+    "@webiny/plugins": "0.0.0",
     "@webiny/react-router": "0.0.0",
     "@webiny/ui": "0.0.0",
     "@webiny/utils": "0.0.0",
diff --git a/packages/app-record-locking/src/components/HeadlessCmsContentEntry/ContentEntryLocker.tsx b/packages/app-record-locking/src/components/HeadlessCmsContentEntry/ContentEntryLocker.tsx
index d279bcffaf8..5d6e69da902 100644
--- a/packages/app-record-locking/src/components/HeadlessCmsContentEntry/ContentEntryLocker.tsx
+++ b/packages/app-record-locking/src/components/HeadlessCmsContentEntry/ContentEntryLocker.tsx
@@ -1,11 +1,12 @@
-import React, { useEffect, useRef } from "react";
+import React, { useEffect } from "react";
 import { useRecordLocking } from "~/hooks";
-import { IIsRecordLockedParams, IRecordLockingIdentity, IRecordLockingLockRecord } from "~/types";
-import {
-    IncomingGenericData,
-    IWebsocketsSubscription,
-    useWebsockets
-} from "@webiny/app-websockets";
+import type {
+    IIsRecordLockedParams,
+    IRecordLockingIdentity,
+    IRecordLockingLockRecord
+} from "~/types";
+import type { IncomingGenericData } from "@webiny/app-websockets";
+import { useWebsockets } from "@webiny/app-websockets";
 import { parseIdentifier } from "@webiny/utils";
 import { useDialogs } from "@webiny/app-admin";
 import styled from "@emotion/styled";
@@ -51,31 +52,30 @@ export const ContentEntryLocker = ({
 }: IContentEntryLockerProps) => {
     const { updateEntryLock, unlockEntry, fetchLockedEntryLockRecord, removeEntryLock } =
         useRecordLocking();
-
-    const subscription = useRef<IWebsocketsSubscription<any>>();
-
     const websockets = useWebsockets();
-
     const { showDialog } = useDialogs();
 
     useEffect(() => {
         if (!entry.id) {
             return;
-        } else if (subscription.current) {
-            subscription.current.off();
         }
         const { id: entryId } = parseIdentifier(entry.id);
 
-        subscription.current = websockets.onMessage<IKickOutWebsocketsMessage>(
+        const removeEntryLockCb = async () => {
+            const record: IIsRecordLockedParams = {
+                id: entryId,
+                $lockingType: model.modelId
+            };
+            removeEntryLock(record);
+            await unlockEntry(record);
+        };
+
+        let onMessageSub = websockets.onMessage<IKickOutWebsocketsMessage>(
             `recordLocking.entry.kickOut.${entryId}`,
             async incoming => {
                 const { user } = incoming.data;
-                const record: IIsRecordLockedParams = {
-                    id: entryId,
-                    $lockingType: model.modelId
-                };
-                removeEntryLock(record);
                 onDisablePrompt(true);
+                await removeEntryLockCb();
                 showDialog({
                     title: "Entry was forcefully unlocked!",
                     content: <ForceUnlocked user={user} />,
@@ -88,10 +88,12 @@ export const ContentEntryLocker = ({
         );
 
         return () => {
-            if (!subscription.current) {
-                return;
-            }
-            subscription.current.off();
+            onMessageSub.off();
+            /**
+             * Lets null subscriptions, just in case it...
+             */
+            // @ts-expect-error
+            onMessageSub = null;
         };
     }, [entry.id, onEntryUnlocked, model.modelId]);
 
@@ -112,7 +114,8 @@ export const ContentEntryLocker = ({
                 if (result) {
                     return;
                 }
-                unlockEntry(record);
+                removeEntryLock(record);
+                await unlockEntry(record);
             })();
         };
     }, [entry.id]);
diff --git a/packages/app-record-locking/src/components/LockedRecord/LockedRecordForceUnlock.tsx b/packages/app-record-locking/src/components/LockedRecord/LockedRecordForceUnlock.tsx
index f90784ba836..effdf281bd9 100644
--- a/packages/app-record-locking/src/components/LockedRecord/LockedRecordForceUnlock.tsx
+++ b/packages/app-record-locking/src/components/LockedRecord/LockedRecordForceUnlock.tsx
@@ -82,8 +82,8 @@ export const LockedRecordForceUnlock = (props: ILockedRecordForceUnlockProps) =>
         });
     }, [props.id, history, navigateTo]);
 
-    const { hasFullAccess } = usePermission();
-    if (!hasFullAccess) {
+    const { canForceUnlock } = usePermission();
+    if (!canForceUnlock) {
         return null;
     }
 
diff --git a/packages/app-record-locking/src/components/assets/lock.svg b/packages/app-record-locking/src/components/assets/lock.svg
index cc0495c35d7..5f649d01e09 100644
--- a/packages/app-record-locking/src/components/assets/lock.svg
+++ b/packages/app-record-locking/src/components/assets/lock.svg
@@ -7,7 +7,7 @@
                 <polygon opacity="0.87" points="0 0 24 0 24 24 0 24"/>
             </g>
             <path d="M18,8 L17,8 L17,6 C17,3.24 14.76,1 12,1 C9.24,1 7,3.24 7,6 L7,8 L6,8 C4.9,8 4,8.9 4,10 L4,20 C4,21.1 4.9,22 6,22 L18,22 C19.1,22 20,21.1 20,20 L20,10 C20,8.9 19.1,8 18,8 Z M12,17 C10.9,17 10,16.1 10,15 C10,13.9 10.9,13 12,13 C13.1,13 14,13.9 14,15 C14,16.1 13.1,17 12,17 Z M9,8 L9,6 C9,4.34 10.34,3 12,3 C13.66,3 15,4.34 15,6 L15,8 L9,8 Z"
-                  fill="#D8D8D8" fill-rule="nonzero"/>
+                  fill="rgba(0, 0, 0, 0.54)" fill-rule="nonzero"/>
         </g>
     </g>
 </svg>
diff --git a/packages/app-record-locking/src/components/permissionRenderer/RecordLockingPermissions.tsx b/packages/app-record-locking/src/components/permissionRenderer/RecordLockingPermissions.tsx
new file mode 100644
index 00000000000..b6974f350fc
--- /dev/null
+++ b/packages/app-record-locking/src/components/permissionRenderer/RecordLockingPermissions.tsx
@@ -0,0 +1,88 @@
+import React, { Fragment, useCallback, useMemo } from "react";
+import { Cell, Grid } from "@webiny/ui/Grid";
+import { Select } from "@webiny/ui/Select";
+import { i18n } from "@webiny/app/i18n";
+import { gridNoPaddingClass, PermissionInfo } from "@webiny/app-admin/components/Permissions";
+import { Form } from "@webiny/form";
+import { RecordLockingSecurityPermission } from "~/types";
+
+const t = i18n.ns("app-record-locking/components/permissionRenderer");
+
+const RECORD_LOCKING_PERMISSION = "recordLocking";
+
+export interface RecordLockingPermissionsProps {
+    value: RecordLockingSecurityPermission[];
+    onChange: (value: RecordLockingSecurityPermission[]) => void;
+}
+
+export const RecordLockingPermissions = ({ value, onChange }: RecordLockingPermissionsProps) => {
+    const onFormChange = useCallback(
+        (data: RecordLockingSecurityPermission) => {
+            const newValue = value.filter(p => {
+                return p.name.startsWith(RECORD_LOCKING_PERMISSION) === false;
+            });
+
+            if (!data.canForceUnlock || data.canForceUnlock === "no") {
+                onChange(newValue);
+                return;
+            }
+
+            onChange([
+                ...newValue,
+                {
+                    name: "recordLocking",
+                    canForceUnlock: "yes"
+                }
+            ]);
+        },
+        [value]
+    );
+
+    const formData = useMemo(() => {
+        if (!Array.isArray(value)) {
+            return {};
+        }
+
+        const hasFullAccess = value.some(item => item.name === "*");
+
+        if (hasFullAccess) {
+            return {
+                canForceUnlock: "yes"
+            };
+        }
+
+        const permissions = value.filter(item => item.name.startsWith(RECORD_LOCKING_PERMISSION));
+
+        if (!permissions.length || !permissions.some(item => !!item.canForceUnlock)) {
+            return {};
+        }
+
+        return {
+            canForceUnlock: "yes"
+        };
+    }, []);
+
+    return (
+        <Form<RecordLockingSecurityPermission> data={formData} onChange={onFormChange}>
+            {({ Bind }) => {
+                return (
+                    <Fragment>
+                        <Grid className={gridNoPaddingClass}>
+                            <Cell span={6}>
+                                <PermissionInfo title={t`Advanced Record Locking`} />
+                            </Cell>
+                            <Cell span={6}>
+                                <Bind name={"canForceUnlock"}>
+                                    <Select label={t`Advanced Record Locking`}>
+                                        <option value={""}>{t`No Access`}</option>
+                                        <option value={"yes"}>{t`Full Access`}</option>
+                                    </Select>
+                                </Bind>
+                            </Cell>
+                        </Grid>
+                    </Fragment>
+                );
+            }}
+        </Form>
+    );
+};
diff --git a/packages/app-record-locking/src/components/permissionRenderer/index.tsx b/packages/app-record-locking/src/components/permissionRenderer/index.tsx
new file mode 100644
index 00000000000..0f48fd6a3b9
--- /dev/null
+++ b/packages/app-record-locking/src/components/permissionRenderer/index.tsx
@@ -0,0 +1,22 @@
+import React from "react";
+import { AccordionItem } from "@webiny/ui/Accordion";
+import { AdminAppPermissionRendererPlugin } from "@webiny/app-admin/types";
+import { ReactComponent as Icon } from "../assets/lock.svg";
+import { RecordLockingPermissions } from "./RecordLockingPermissions";
+
+export const recordLockingPermissionRenderer: AdminAppPermissionRendererPlugin = {
+    type: "admin-app-permissions-renderer",
+    name: "admin-app-permissions-renderer-record-locking",
+    render(props) {
+        return (
+            <AccordionItem
+                icon={<Icon />}
+                title={"Record Locking"}
+                description={"Manage Record Locking Permissions."}
+                data-testid={"permission.recordLocking"}
+            >
+                <RecordLockingPermissions {...props} />
+            </AccordionItem>
+        );
+    }
+};
diff --git a/packages/app-record-locking/src/hooks/usePermission.ts b/packages/app-record-locking/src/hooks/usePermission.ts
index af78d623cb3..5b4992bcee6 100644
--- a/packages/app-record-locking/src/hooks/usePermission.ts
+++ b/packages/app-record-locking/src/hooks/usePermission.ts
@@ -4,11 +4,16 @@ import { useSecurity } from "@webiny/app-security";
 export const usePermission = () => {
     const { identity, getPermission } = useSecurity();
 
-    const hasFullAccess = useMemo(() => {
-        return !!getPermission("recordLocking.*");
-    }, [identity]);
+    const canForceUnlock = useMemo(() => {
+        const hasFullAccess = !!getPermission("recordLocking.*");
+        if (hasFullAccess) {
+            return true;
+        }
+        const permission = getPermission("recordLocking");
+        return permission?.canForceUnlock === "yes";
+    }, [identity?.permissions]);
 
     return {
-        hasFullAccess
+        canForceUnlock
     };
 };
diff --git a/packages/app-record-locking/src/index.tsx b/packages/app-record-locking/src/index.tsx
index b15c5dc9e16..01dfd9c3b72 100644
--- a/packages/app-record-locking/src/index.tsx
+++ b/packages/app-record-locking/src/index.tsx
@@ -4,6 +4,8 @@ import { RecordLockingProvider as RecordLockingProviderComponent } from "~/compo
 import { HeadlessCmsActionsAcoCell } from "~/components/HeadlessCmsActionsAcoCell";
 import { HeadlessCmsContentEntry } from "~/components/HeadlessCmsContentEntry";
 import { useWcp } from "@webiny/app-wcp";
+import { plugins } from "@webiny/plugins";
+import { recordLockingPermissionRenderer } from "~/components/permissionRenderer";
 
 export * from "~/components/RecordLockingProvider";
 export * from "~/hooks";
@@ -28,6 +30,7 @@ export const RecordLocking = () => {
     if (!canUseRecordLocking()) {
         return null;
     }
+    plugins.register(recordLockingPermissionRenderer);
 
     return (
         <>
diff --git a/packages/app-record-locking/src/types.ts b/packages/app-record-locking/src/types.ts
index 9a4d679ebf9..f77134d87ed 100644
--- a/packages/app-record-locking/src/types.ts
+++ b/packages/app-record-locking/src/types.ts
@@ -1,6 +1,7 @@
 import { EntryTableItem } from "@webiny/app-headless-cms/types";
 import { GenericRecord } from "@webiny/app/types";
 import { IRecordLockingUnlockEntryResult } from "~/domain/abstractions/IRecordLockingUnlockEntry";
+import { SecurityPermission } from "@webiny/app-security/types";
 
 export interface IRecordLockingIdentity {
     id: string;
@@ -89,3 +90,7 @@ export interface IRecordLockingError<T = GenericRecord> {
     code: string;
     data?: T;
 }
+
+export interface RecordLockingSecurityPermission extends SecurityPermission {
+    canForceUnlock?: string;
+}
diff --git a/packages/app-record-locking/tsconfig.build.json b/packages/app-record-locking/tsconfig.build.json
index bf8abc7f4f3..cc1395982f1 100644
--- a/packages/app-record-locking/tsconfig.build.json
+++ b/packages/app-record-locking/tsconfig.build.json
@@ -5,6 +5,8 @@
     { "path": "../app/tsconfig.build.json" },
     { "path": "../app-aco/tsconfig.build.json" },
     { "path": "../app-admin/tsconfig.build.json" },
+    { "path": "../form/tsconfig.build.json" },
+    { "path": "../plugins/tsconfig.build.json" },
     { "path": "../app-headless-cms/tsconfig.build.json" },
     { "path": "../app-security/tsconfig.build.json" },
     { "path": "../app-wcp/tsconfig.build.json" },
diff --git a/packages/app-record-locking/tsconfig.json b/packages/app-record-locking/tsconfig.json
index b2bdfb27f1f..db6435a4c90 100644
--- a/packages/app-record-locking/tsconfig.json
+++ b/packages/app-record-locking/tsconfig.json
@@ -5,6 +5,8 @@
     { "path": "../app" },
     { "path": "../app-aco" },
     { "path": "../app-admin" },
+    { "path": "../form" },
+    { "path": "../plugins" },
     { "path": "../app-headless-cms" },
     { "path": "../app-security" },
     { "path": "../app-wcp" },
@@ -27,6 +29,10 @@
       "@webiny/app-aco": ["../app-aco/src"],
       "@webiny/app-admin/*": ["../app-admin/src/*"],
       "@webiny/app-admin": ["../app-admin/src"],
+      "@webiny/form/*": ["../form/src/*"],
+      "@webiny/form": ["../form/src"],
+      "@webiny/plugins/*": ["../plugins/src/*"],
+      "@webiny/plugins": ["../plugins/src"],
       "@webiny/app-headless-cms/*": ["../app-headless-cms/src/*"],
       "@webiny/app-headless-cms": ["../app-headless-cms/src"],
       "@webiny/app-security/*": ["../app-security/src/*"],
diff --git a/packages/app-websockets/src/WebsocketsContextProvider.tsx b/packages/app-websockets/src/WebsocketsContextProvider.tsx
index 2fa8d86dae8..b5d453d7ea8 100644
--- a/packages/app-websockets/src/WebsocketsContextProvider.tsx
+++ b/packages/app-websockets/src/WebsocketsContextProvider.tsx
@@ -6,6 +6,8 @@ import {
     IncomingGenericData,
     IWebsocketsContext,
     IWebsocketsContextSendCallable,
+    IWebsocketsManagerCloseEvent,
+    IWebsocketsManagerErrorEvent,
     WebsocketsCloseCode
 } from "~/types";
 import {
@@ -68,6 +70,7 @@ export const WebsocketsContextProvider = (props: IWebsocketsContextProviderProps
 
         return manager;
     }, []);
+
     /**
      * We need this useEffect to close the websocket connection and remove window focus event in case component is unmounted.
      * This will, probably, happen only during the development phase.
@@ -78,23 +81,35 @@ export const WebsocketsContextProvider = (props: IWebsocketsContextProviderProps
         /**
          * We want to add a window event listener which will check if the connection is closed, and if its - it will connect again.
          */
-        const fn = () => {
-            if (!socketsRef.current) {
-                return;
-            } else if (socketsRef.current.isClosed()) {
-                console.log("Running auto-reconnect on focus.");
-                socketsRef.current.connect();
-            }
-        };
-        window.addEventListener("focus", fn);
+        const abortController = new AbortController();
 
-        return () => {
-            window.removeEventListener("focus", fn);
-            // if (!socketsRef.current) {
-            //     return;
-            // }
+        window.addEventListener(
+            "focus",
+            () => {
+                if (!socketsRef.current) {
+                    return;
+                } else if (socketsRef.current.isClosed()) {
+                    console.log("Running auto-reconnect on focus.");
+                    socketsRef.current.connect();
+                }
+            },
+            { signal: abortController.signal }
+        );
+        window.addEventListener(
+            "close",
+            () => {
+                subscriptionManager.triggerOnClose(
+                    new CloseEvent("windowClose", {
+                        code: WebsocketsCloseCode.GOING_AWAY,
+                        reason: "Closing Window or Tab."
+                    })
+                );
+            },
+            { signal: abortController.signal }
+        );
 
-            // socketsRef.current.close(WebsocketsCloseCode.NORMAL, "Component unmounted.");
+        return () => {
+            abortController.abort();
         };
     }, []);
 
@@ -185,6 +200,24 @@ export const WebsocketsContextProvider = (props: IWebsocketsContextProviderProps
         [socketsRef.current]
     );
 
+    const onError = useCallback(
+        (cb: (data: IWebsocketsManagerErrorEvent) => void) => {
+            return socketsRef.current!.onError(data => {
+                return cb(data);
+            });
+        },
+        [socketsRef.current]
+    );
+
+    const onClose = useCallback(
+        (cb: (data: IWebsocketsManagerCloseEvent) => void) => {
+            return socketsRef.current!.onClose(data => {
+                return cb(data);
+            });
+        },
+        [socketsRef.current]
+    );
+
     if (!socketsRef.current) {
         return props.loader || null;
     }
@@ -192,7 +225,9 @@ export const WebsocketsContextProvider = (props: IWebsocketsContextProviderProps
     const value: IWebsocketsContext = {
         send,
         createAction,
-        onMessage
+        onMessage,
+        onError,
+        onClose
     };
     return <WebsocketsContext.Provider value={value} {...props} />;
 };
diff --git a/packages/app-websockets/src/domain/WebsocketsConnection.ts b/packages/app-websockets/src/domain/WebsocketsConnection.ts
index 45a421f0a0c..c4808024942 100644
--- a/packages/app-websockets/src/domain/WebsocketsConnection.ts
+++ b/packages/app-websockets/src/domain/WebsocketsConnection.ts
@@ -154,6 +154,17 @@ export class WebsocketsConnection implements IWebsocketsConnection {
         });
         connectionCache.ws.addEventListener("error", event => {
             console.info(`Error in the Websocket connection.`, event);
+            /**
+             * Let's close it if possible.
+             * It will reopen automatically.
+             */
+            if (connectionCache.ws?.close) {
+                try {
+                    connectionCache.ws.close();
+                } catch (ex) {
+                    console.error(ex);
+                }
+            }
             return this.subscriptionManager.triggerOnError(event);
         });
 
diff --git a/packages/app-websockets/src/types.ts b/packages/app-websockets/src/types.ts
index 9d6824ef108..70d3a3716a6 100644
--- a/packages/app-websockets/src/types.ts
+++ b/packages/app-websockets/src/types.ts
@@ -1,6 +1,8 @@
 import {
     IGenericData,
     IWebsocketsAction,
+    IWebsocketsManagerCloseEvent,
+    IWebsocketsManagerErrorEvent,
     IWebsocketsManagerMessageEvent,
     IWebsocketsSubscription
 } from "~/domain/types";
@@ -25,10 +27,24 @@ export interface ISocketsContextOnMessageCallable {
     ): IWebsocketsSubscription<IWebsocketsManagerMessageEvent<T>>;
 }
 
+export interface ISocketsContextOnErrorCallable {
+    (
+        cb: (data: IWebsocketsManagerErrorEvent) => void
+    ): IWebsocketsSubscription<IWebsocketsManagerErrorEvent>;
+}
+
+export interface ISocketsContextOnCloseCallable {
+    (
+        cb: (data: IWebsocketsManagerCloseEvent) => void
+    ): IWebsocketsSubscription<IWebsocketsManagerCloseEvent>;
+}
+
 export interface IWebsocketsContext {
     send: IWebsocketsContextSendCallable;
     createAction: IWebsocketsContextCreateActionCallable;
     onMessage: ISocketsContextOnMessageCallable;
+    onError: ISocketsContextOnErrorCallable;
+    onClose: ISocketsContextOnCloseCallable;
 }
 
 export interface IncomingGenericData extends IGenericData {
diff --git a/packages/pulumi-aws/src/apps/api/ApiGateway.ts b/packages/pulumi-aws/src/apps/api/ApiGateway.ts
index 56eaaa17ad4..c3f74913e19 100644
--- a/packages/pulumi-aws/src/apps/api/ApiGateway.ts
+++ b/packages/pulumi-aws/src/apps/api/ApiGateway.ts
@@ -25,7 +25,15 @@ export const ApiGateway = createAppModule({
             name: "default",
             config: {
                 apiId: api.output.id,
-                autoDeploy: true
+                autoDeploy: true,
+                defaultRouteSettings: {
+                    // Only enable when debugging. Note that by default, API Gateway does not
+                    // have the required permissions to write logs to CloudWatch logs. More:
+                    // https://coady.tech/aws-cloudwatch-logs-arn/
+                    // loggingLevel: "INFO",
+                    throttlingBurstLimit: 5000,
+                    throttlingRateLimit: 10000
+                }
             }
         });
 
diff --git a/yarn.lock b/yarn.lock
index 8b5e330dfe3..3afffa2625a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -16332,6 +16332,8 @@ __metadata:
     "@webiny/app-websockets": 0.0.0
     "@webiny/cli": 0.0.0
     "@webiny/error": 0.0.0
+    "@webiny/form": 0.0.0
+    "@webiny/plugins": 0.0.0
     "@webiny/project-utils": 0.0.0
     "@webiny/react-router": 0.0.0
     "@webiny/ui": 0.0.0

From 4931327c2d1dd02b32d5b3f68872a89930441cf5 Mon Sep 17 00:00:00 2001
From: Leonardo Giacone <leonardo@webiny.com>
Date: Fri, 28 Mar 2025 10:00:16 +0100
Subject: [PATCH 37/52] fix(api-aco): `extensions` default model field (#4580)

---
 packages/api-aco/src/folder/folder.model.ts   | 20 ++++++++++++++++++-
 .../src/createFieldsList.ts                   |  4 ++--
 2 files changed, 21 insertions(+), 3 deletions(-)

diff --git a/packages/api-aco/src/folder/folder.model.ts b/packages/api-aco/src/folder/folder.model.ts
index 073b7837eb6..95e4d809e56 100644
--- a/packages/api-aco/src/folder/folder.model.ts
+++ b/packages/api-aco/src/folder/folder.model.ts
@@ -113,6 +113,17 @@ const permissionsField = () =>
         }
     });
 
+const extensionsField = () =>
+    createModelField({
+        label: "Extensions",
+        fieldId: "extensions",
+        type: "object",
+        settings: {
+            layout: [],
+            fields: []
+        }
+    });
+
 export const FOLDER_MODEL_ID = "acoFolder";
 
 export const createFolderModel = () => {
@@ -127,6 +138,13 @@ export const createFolderModel = () => {
             // flp: true
         },
         titleFieldId: "title",
-        fields: [titleField(), slugField(), typeField(), parentIdField(), permissionsField()]
+        fields: [
+            titleField(),
+            slugField(),
+            typeField(),
+            parentIdField(),
+            permissionsField(),
+            extensionsField()
+        ]
     });
 };
diff --git a/packages/app-headless-cms-common/src/createFieldsList.ts b/packages/app-headless-cms-common/src/createFieldsList.ts
index f7228d2385d..bda6855183b 100644
--- a/packages/app-headless-cms-common/src/createFieldsList.ts
+++ b/packages/app-headless-cms-common/src/createFieldsList.ts
@@ -47,10 +47,10 @@ export function createFieldsList({
         })
         .filter(Boolean);
     /**
-     * If there are no fields, let's always load the `id` field.
+     * If there are no fields, let's always load the `_empty` field.
      */
     if (fields.length === 0) {
-        fields.push("id");
+        fields.push("_empty");
     }
     return fields.join("\n");
 }

From fdf4a5e06080e16be06ca5b538fd17ed70603706 Mon Sep 17 00:00:00 2001
From: Adrian Smijulj <adrian1358@gmail.com>
Date: Fri, 28 Mar 2025 10:02:54 +0100
Subject: [PATCH 38/52] fix: expand PB / FM APIs to allow GQL API-based data
 migrations (#4581)

---
 .../src/plugins/graphqlFileStorageS3.ts       |   1 +
 .../src/operations/pages/index.ts             |  40 +++-
 .../src/operations/pages/index.ts             |  16 ++
 .../__tests__/graphql/createPage.test.ts      | 209 ++++++++++++++++++
 .../__tests__/graphql/graphql/pages.ts        |  14 ++
 .../__tests__/graphql/useGqlHandler.ts        |   4 +
 .../src/graphql/crud/categories.crud.ts       |  11 +-
 .../src/graphql/crud/categories/validation.ts |  10 +-
 .../src/graphql/crud/menus.crud.ts            |  11 +-
 .../src/graphql/crud/menus/validation.ts      |  10 +-
 .../src/graphql/crud/pages.crud.ts            | 163 +++++++++++++-
 .../src/graphql/crud/utils/formatDate.ts      |  12 +
 .../src/graphql/crud/utils/getDate.ts         |  14 ++
 .../src/graphql/crud/utils/getIdentity.ts     |  16 ++
 .../src/graphql/graphql/base.gql.ts           |   6 +
 .../src/graphql/graphql/categories.gql.ts     |   2 +
 .../src/graphql/graphql/menus.gql.ts          |   2 +
 .../src/graphql/graphql/pages.gql.ts          |  26 +++
 .../api-page-builder/src/graphql/types.ts     | 121 ++++++++++
 .../prerendering/hooks/afterSettingsUpdate.ts |  57 +----
 20 files changed, 670 insertions(+), 75 deletions(-)
 create mode 100644 packages/api-page-builder/__tests__/graphql/createPage.test.ts
 create mode 100644 packages/api-page-builder/src/graphql/crud/utils/formatDate.ts
 create mode 100644 packages/api-page-builder/src/graphql/crud/utils/getDate.ts
 create mode 100644 packages/api-page-builder/src/graphql/crud/utils/getIdentity.ts

diff --git a/packages/api-file-manager-s3/src/plugins/graphqlFileStorageS3.ts b/packages/api-file-manager-s3/src/plugins/graphqlFileStorageS3.ts
index 43ffb13fc85..80fba5e9171 100644
--- a/packages/api-file-manager-s3/src/plugins/graphqlFileStorageS3.ts
+++ b/packages/api-file-manager-s3/src/plugins/graphqlFileStorageS3.ts
@@ -25,6 +25,7 @@ const plugin: GraphQLSchemaPlugin<FileManagerContext> = {
             }
 
             input PreSignedPostPayloadInput {
+                id: ID
                 name: String!
                 type: String!
                 size: Long!
diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts b/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts
index b523ec37d03..239b80bef5b 100644
--- a/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts
+++ b/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts
@@ -69,6 +69,7 @@ export interface CreatePageStorageOperationsParams {
     elasticsearch: Client;
     plugins: PluginsContainer;
 }
+
 export const createPageStorageOperations = (
     params: CreatePageStorageOperationsParams
 ): PageStorageOperations => {
@@ -86,6 +87,11 @@ export const createPageStorageOperations = (
             SK: createLatestSortKey()
         };
 
+        const publishedKeys = {
+            ...versionKeys,
+            SK: createPublishedSortKey()
+        };
+
         const entityBatch = createEntityWriteBatch({
             entity,
             put: [
@@ -103,17 +109,41 @@ export const createPageStorageOperations = (
         });
 
         const esData = getESLatestPageData(plugins, page, input);
-        try {
-            await entityBatch.execute();
 
-            await put({
-                entity: esEntity,
-                item: {
+        const elasticsearchEntityBatch = createEntityWriteBatch({
+            entity: esEntity,
+            put: [
+                {
                     index: configurations.es(page).index,
                     data: esData,
                     ...latestKeys
                 }
+            ]
+        });
+
+        if (page.status === "published") {
+            entityBatch.put({
+                ...page,
+                ...publishedKeys,
+                TYPE: createPublishedType()
+            });
+
+            entityBatch.put({
+                ...page,
+                TYPE: createPublishedPathType(),
+                PK: createPathPartitionKey(page),
+                SK: createPathSortKey(page)
             });
+
+            elasticsearchEntityBatch.put({
+                index: configurations.es(page).index,
+                data: getESPublishedPageData(plugins, page),
+                ...publishedKeys
+            });
+        }
+        try {
+            await entityBatch.execute();
+            await elasticsearchEntityBatch.execute();
             return page;
         } catch (ex) {
             throw new WebinyError(
diff --git a/packages/api-page-builder-so-ddb/src/operations/pages/index.ts b/packages/api-page-builder-so-ddb/src/operations/pages/index.ts
index 1938bace62b..75fb2868e0a 100644
--- a/packages/api-page-builder-so-ddb/src/operations/pages/index.ts
+++ b/packages/api-page-builder-so-ddb/src/operations/pages/index.ts
@@ -81,6 +81,7 @@ export interface CreatePageStorageOperationsParams {
     entity: Entity<any>;
     plugins: PluginsContainer;
 }
+
 export const createPageStorageOperations = (
     params: CreatePageStorageOperationsParams
 ): PageStorageOperations => {
@@ -98,6 +99,11 @@ export const createPageStorageOperations = (
             SK: createLatestSortKey(page)
         };
 
+        const publishedKeys = {
+            PK: createPublishedPartitionKey(page),
+            SK: createPublishedSortKey(page)
+        };
+
         const titleLC = page.title.toLowerCase();
         /**
          * We need to create
@@ -122,6 +128,16 @@ export const createPageStorageOperations = (
             ]
         });
 
+        if (page.status === "published") {
+            entityBatch.put({
+                ...page,
+                ...publishedKeys,
+                GSI1_PK: createPathPartitionKey(page),
+                GSI1_SK: page.path,
+                TYPE: createPublishedType()
+            });
+        }
+
         try {
             await entityBatch.execute();
             return page;
diff --git a/packages/api-page-builder/__tests__/graphql/createPage.test.ts b/packages/api-page-builder/__tests__/graphql/createPage.test.ts
new file mode 100644
index 00000000000..dea0a692297
--- /dev/null
+++ b/packages/api-page-builder/__tests__/graphql/createPage.test.ts
@@ -0,0 +1,209 @@
+import useGqlHandler from "./useGqlHandler";
+
+jest.setTimeout(100000);
+
+describe("CRUD Test", () => {
+    const handler = useGqlHandler();
+
+    const { createCategory, createPageV2, getPage, getPublishedPage } = handler;
+
+    it("creating pages via the new createPagesV2 mutation", async () => {
+        await createCategory({
+            data: {
+                slug: `slug`,
+                name: `name`,
+                url: `/some-url/`,
+                layout: `layout`
+            }
+        });
+
+        const page = {
+            id: "67e15c96026bd2000222d698#0001",
+            pid: "67e15c96026bd2000222d698",
+            category: "slug",
+            version: 1,
+            title: "Welcome to Webiny",
+            path: "/welcome-to-webiny",
+            content: {
+                id: "Fv1PpPWu-",
+                type: "document",
+                data: {
+                    settings: {}
+                },
+                elements: []
+            },
+            status: "published",
+            publishedOn: "2025-03-24T13:22:30.918Z",
+            settings: {
+                general: {
+                    snippet: null,
+                    tags: null,
+                    layout: "static",
+                    image: null
+                },
+                social: {
+                    meta: [],
+                    title: null,
+                    description: null,
+                    image: null
+                },
+                seo: {
+                    title: null,
+                    description: null,
+                    meta: []
+                }
+            },
+            createdOn: "2025-03-24T13:22:30.363Z",
+            createdBy: {
+                id: "67e15c7d026bd2000222d67a",
+                displayName: "ad min",
+                type: "admin"
+            }
+        };
+
+        // The V2 of the createPage mutation should allow us to create pages with
+        // predefined `createdOn`, `createdBy`, `id`, and also immediately have the
+        // page published.
+        await createPageV2({ data: page });
+
+        const [getPageResponse] = await getPage({ id: page.id });
+
+        expect(getPageResponse).toMatchObject({
+            data: {
+                pageBuilder: {
+                    getPage: {
+                        data: {
+                            id: "67e15c96026bd2000222d698#0001",
+                            pid: "67e15c96026bd2000222d698",
+                            editor: "page-builder",
+                            category: {
+                                slug: "slug"
+                            },
+                            version: 1,
+                            title: "Welcome to Webiny",
+                            path: "/welcome-to-webiny",
+                            url: "https://www.test.com/welcome-to-webiny",
+                            content: {
+                                id: "Fv1PpPWu-",
+                                type: "document",
+                                data: {
+                                    settings: {}
+                                },
+                                elements: []
+                            },
+                            savedOn: "2025-03-24T13:22:30.363Z",
+                            status: "published",
+                            locked: true,
+                            publishedOn: "2025-03-24T13:22:30.918Z",
+                            revisions: [
+                                {
+                                    id: "67e15c96026bd2000222d698#0001",
+                                    status: "published",
+                                    locked: true,
+                                    version: 1
+                                }
+                            ],
+                            settings: {
+                                general: {
+                                    snippet: null,
+                                    tags: null,
+                                    layout: "static",
+                                    image: null
+                                },
+                                social: {
+                                    meta: [],
+                                    title: null,
+                                    description: null,
+                                    image: null
+                                },
+                                seo: {
+                                    title: null,
+                                    description: null,
+                                    meta: []
+                                }
+                            },
+                            createdFrom: null,
+                            createdOn: "2025-03-24T13:22:30.363Z",
+                            createdBy: {
+                                id: "67e15c7d026bd2000222d67a",
+                                displayName: "ad min",
+                                type: "admin"
+                            }
+                        },
+                        error: null
+                    }
+                }
+            }
+        });
+
+        const [getPublishedPageResponse] = await getPublishedPage({ id: page.id });
+
+        expect(getPublishedPageResponse).toMatchObject({
+            data: {
+                pageBuilder: {
+                    getPublishedPage: {
+                        data: {
+                            id: "67e15c96026bd2000222d698#0001",
+                            pid: "67e15c96026bd2000222d698",
+                            editor: "page-builder",
+                            category: {
+                                slug: "slug"
+                            },
+                            version: 1,
+                            title: "Welcome to Webiny",
+                            path: "/welcome-to-webiny",
+                            url: "https://www.test.com/welcome-to-webiny",
+                            content: {
+                                id: "Fv1PpPWu-",
+                                type: "document",
+                                data: {
+                                    settings: {}
+                                },
+                                elements: []
+                            },
+                            savedOn: "2025-03-24T13:22:30.363Z",
+                            status: "published",
+                            locked: true,
+                            publishedOn: "2025-03-24T13:22:30.918Z",
+                            revisions: [
+                                {
+                                    id: "67e15c96026bd2000222d698#0001",
+                                    status: "published",
+                                    locked: true,
+                                    version: 1
+                                }
+                            ],
+                            settings: {
+                                general: {
+                                    snippet: null,
+                                    tags: null,
+                                    layout: "static",
+                                    image: null
+                                },
+                                social: {
+                                    meta: [],
+                                    title: null,
+                                    description: null,
+                                    image: null
+                                },
+                                seo: {
+                                    title: null,
+                                    description: null,
+                                    meta: []
+                                }
+                            },
+                            createdFrom: null,
+                            createdOn: "2025-03-24T13:22:30.363Z",
+                            createdBy: {
+                                id: "67e15c7d026bd2000222d67a",
+                                displayName: "ad min",
+                                type: "admin"
+                            }
+                        },
+                        error: null
+                    }
+                }
+            }
+        });
+    });
+});
diff --git a/packages/api-page-builder/__tests__/graphql/graphql/pages.ts b/packages/api-page-builder/__tests__/graphql/graphql/pages.ts
index 9e2cfeaa999..34ae1f7c1c0 100644
--- a/packages/api-page-builder/__tests__/graphql/graphql/pages.ts
+++ b/packages/api-page-builder/__tests__/graphql/graphql/pages.ts
@@ -198,7 +198,21 @@ export const createPageCreateGraphQl = (params: CreateDataFieldsParams = {}) =>
     `;
 };
 
+export const createPageCreateV2GraphQl = (params: CreateDataFieldsParams = {}) => {
+    return /* GraphQL */ `
+        mutation CreatePageV2($data: PbCreatePageV2Input!) {
+            pageBuilder {
+                createPageV2(data: $data) {
+                    data ${createDataFields(params)}
+                    error ${ERROR_FIELD}
+                }
+            }
+        }
+    `;
+};
+
 export const CREATE_PAGE = createPageCreateGraphQl();
+export const CREATE_PAGE_V2 = createPageCreateV2GraphQl();
 
 export const createPageUpdateGraphQl = (params: CreateDataFieldsParams = {}) => {
     return /* GraphQL */ `
diff --git a/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts b/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts
index d20f93a6350..7c681647fd4 100644
--- a/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts
+++ b/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts
@@ -26,6 +26,7 @@ import {
 } from "./graphql/pageElements";
 import {
     CREATE_PAGE,
+    CREATE_PAGE_V2,
     DELETE_PAGE,
     DUPLICATE_PAGE,
     GET_PAGE,
@@ -241,6 +242,9 @@ export default ({ permissions, identity, plugins }: Params = {}) => {
         async createPage(variables: Record<string, any>) {
             return invoke({ body: { query: CREATE_PAGE, variables } });
         },
+        async createPageV2(variables: Record<string, any>) {
+            return invoke({ body: { query: CREATE_PAGE_V2, variables } });
+        },
         async duplicatePage(variables: Record<string, any>) {
             return invoke({ body: { query: DUPLICATE_PAGE, variables } });
         },
diff --git a/packages/api-page-builder/src/graphql/crud/categories.crud.ts b/packages/api-page-builder/src/graphql/crud/categories.crud.ts
index 158335ce06e..f94e6e68ac6 100644
--- a/packages/api-page-builder/src/graphql/crud/categories.crud.ts
+++ b/packages/api-page-builder/src/graphql/crud/categories.crud.ts
@@ -23,6 +23,8 @@ import {
 import { createZodError, removeUndefinedValues } from "@webiny/utils";
 import { CategoriesPermissions } from "~/graphql/crud/permissions/CategoriesPermissions";
 import { PagesPermissions } from "~/graphql/crud/permissions/PagesPermissions";
+import { getDate } from "~/graphql/crud/utils/getDate";
+import { getIdentity } from "~/graphql/crud/utils/getIdentity";
 
 export interface CreateCategoriesCrudParams {
     context: PbContext;
@@ -172,17 +174,14 @@ export const createCategoriesCrud = (params: CreateCategoriesCrudParams): Catego
             }
 
             const identity = context.security.getIdentity();
+            const currentDateTime = new Date();
 
             const data = validationResult.data;
 
             const category: Category = {
                 ...data,
-                createdOn: new Date().toISOString(),
-                createdBy: {
-                    id: identity.id,
-                    type: identity.type,
-                    displayName: identity.displayName
-                },
+                createdOn: getDate(input.createdOn, currentDateTime),
+                createdBy: getIdentity(input.createdBy, identity),
                 tenant: getTenantId(),
                 locale: getLocaleCode()
             };
diff --git a/packages/api-page-builder/src/graphql/crud/categories/validation.ts b/packages/api-page-builder/src/graphql/crud/categories/validation.ts
index 9dd72aee443..bd1b10e31fc 100644
--- a/packages/api-page-builder/src/graphql/crud/categories/validation.ts
+++ b/packages/api-page-builder/src/graphql/crud/categories/validation.ts
@@ -8,7 +8,15 @@ const baseValidation = zod.object({
 
 export const createCategoryCreateValidation = () => {
     return baseValidation.extend({
-        slug: zod.string().min(1).max(100)
+        slug: zod.string().min(1).max(100),
+        createdOn: zod.date().optional(),
+        createdBy: zod
+            .object({
+                id: zod.string(),
+                type: zod.string(),
+                displayName: zod.string().nullable()
+            })
+            .optional()
     });
 };
 
diff --git a/packages/api-page-builder/src/graphql/crud/menus.crud.ts b/packages/api-page-builder/src/graphql/crud/menus.crud.ts
index 8bdb7804c9a..2efa4b84f62 100644
--- a/packages/api-page-builder/src/graphql/crud/menus.crud.ts
+++ b/packages/api-page-builder/src/graphql/crud/menus.crud.ts
@@ -23,6 +23,8 @@ import {
 } from "~/graphql/crud/menus/validation";
 import { createZodError, removeUndefinedValues } from "@webiny/utils";
 import { MenusPermissions } from "~/graphql/crud/permissions/MenusPermissions";
+import { getIdentity } from "./utils/getIdentity";
+import { getDate } from "./utils/getDate";
 
 export interface CreateMenuCrudParams {
     context: PbContext;
@@ -175,16 +177,13 @@ export const createMenuCrud = (params: CreateMenuCrudParams): MenusCrud => {
             }
 
             const identity = context.security.getIdentity();
+            const currentDateTime = new Date();
 
             const menu: Menu = {
                 ...data,
                 items: data.items || [],
-                createdOn: new Date().toISOString(),
-                createdBy: {
-                    id: identity.id,
-                    type: identity.type,
-                    displayName: identity.displayName
-                },
+                createdOn: getDate(input.createdOn, currentDateTime),
+                createdBy: getIdentity(input.createdBy, identity),
                 tenant: getTenantId(),
                 locale: getLocaleCode()
             };
diff --git a/packages/api-page-builder/src/graphql/crud/menus/validation.ts b/packages/api-page-builder/src/graphql/crud/menus/validation.ts
index b048c915bef..f150ccf9a29 100644
--- a/packages/api-page-builder/src/graphql/crud/menus/validation.ts
+++ b/packages/api-page-builder/src/graphql/crud/menus/validation.ts
@@ -8,7 +8,15 @@ const baseValidation = zod.object({
 
 export const createMenuCreateValidation = () => {
     return baseValidation.extend({
-        slug: zod.string().min(1).max(100)
+        slug: zod.string().min(1).max(100),
+        createdOn: zod.date().optional(),
+        createdBy: zod
+            .object({
+                id: zod.string(),
+                type: zod.string(),
+                displayName: zod.string().nullable()
+            })
+            .optional()
     });
 };
 
diff --git a/packages/api-page-builder/src/graphql/crud/pages.crud.ts b/packages/api-page-builder/src/graphql/crud/pages.crud.ts
index 7f8fb4a8e84..5105867920c 100644
--- a/packages/api-page-builder/src/graphql/crud/pages.crud.ts
+++ b/packages/api-page-builder/src/graphql/crud/pages.crud.ts
@@ -52,6 +52,8 @@ import {
 import { createCompression } from "~/graphql/crud/pages/compression";
 import { PagesPermissions } from "./permissions/PagesPermissions";
 import { PageContent } from "./pages/PageContent";
+import { getDate } from "~/graphql/crud/utils/getDate";
+import { getIdentity } from "~/graphql/crud/utils/getIdentity";
 
 const STATUS_DRAFT = "draft";
 const STATUS_PUBLISHED = "published";
@@ -289,12 +291,12 @@ export const createPageCrud = (params: CreatePageCrudParams): PagesCrud => {
         async processPageContent(page) {
             return processPageContent(page, pageElementProcessors);
         },
-        async createPage(this: PageBuilderContextObject, slug, meta): Promise<any> {
+        async createPage(this: PageBuilderContextObject, categorySlug, meta): Promise<any> {
             await pagesPermissions.ensure({ rwd: "w" });
 
-            const category = await this.getCategory(slug);
+            const category = await this.getCategory(categorySlug);
             if (!category) {
-                throw new NotFoundError(`Category with slug "${slug}" not found.`);
+                throw new NotFoundError(`Category with slug "${categorySlug}" not found.`);
             }
 
             const title = "Untitled";
@@ -399,7 +401,160 @@ export const createPageCrud = (params: CreatePageCrudParams): PagesCrud => {
 
                 await storageOperations.pages.create({
                     input: {
-                        slug
+                        slug: categorySlug
+                    },
+                    page: await compressPage(page)
+                });
+                await onPageAfterCreate.publish({ page, meta });
+
+                return page;
+            } catch (ex) {
+                throw new WebinyError(
+                    ex.message || "Could not create new page.",
+                    ex.code || "CREATE_PAGE_ERROR",
+                    {
+                        ...(ex.data || {}),
+                        page
+                    }
+                );
+            }
+        },
+
+        async createPageV2(this: PageBuilderContextObject, input, meta): Promise<any> {
+            await pagesPermissions.ensure({ rwd: "w" });
+
+            const categorySlug = input.category;
+            if (!categorySlug) {
+                throw new WebinyError("Category slug is missing.", "CATEGORY_SLUG_MISSING");
+            }
+
+            const category = await this.getCategory(categorySlug);
+            if (!category) {
+                throw new NotFoundError(`Category with slug "${categorySlug}" not found.`);
+            }
+
+            const title = input.title || "Untitled";
+
+            let pagePath = input.path;
+            if (!pagePath) {
+                if (category.slug === "static") {
+                    pagePath = normalizePath("untitled-" + uniqid.time()) as string;
+                } else {
+                    pagePath = normalizePath(
+                        [category.url, "untitled-" + uniqid.time()].join("/").replace(/\/\//g, "/")
+                    ) as string;
+                }
+            }
+
+            const result = await createPageCreateValidation().safeParseAsync({
+                category: category.slug
+            });
+            if (!result.success) {
+                throw createZodError(result.error);
+            }
+
+            const currentIdentity = context.security.getIdentity();
+            const currentDateTime = new Date();
+
+            let pageId = "",
+                version = 1;
+            if (input.id) {
+                const splitId = input.id.split("#");
+                pageId = splitId[0];
+                version = Number(splitId[1]);
+            } else if (input.pid) {
+                pageId = input.pid;
+            } else {
+                pageId = mdbid();
+            }
+
+            if (input.version) {
+                version = input.version;
+            }
+
+            const id = createIdentifier({
+                id: pageId,
+                version: 1
+            });
+
+            const rawSettings = input.settings || {
+                general: {
+                    layout: category.layout
+                },
+                social: {
+                    description: null,
+                    image: null,
+                    meta: [],
+                    title: null
+                },
+                seo: {
+                    title: null,
+                    description: null,
+                    meta: []
+                }
+            };
+
+            const validation = createPageSettingsUpdateValidation();
+            const settingsValidationResult = validation.safeParse(rawSettings);
+            if (!settingsValidationResult.success) {
+                throw createZodError(settingsValidationResult.error);
+            }
+
+            const settings = settingsValidationResult.data;
+            const status = input.status || STATUS_DRAFT;
+            const locked = status !== STATUS_DRAFT;
+
+            let publishedOn = null;
+            if (status === STATUS_PUBLISHED) {
+                publishedOn = getDate(input.publishedOn, currentDateTime);
+            }
+
+            const page: Page = {
+                id,
+                pid: pageId,
+                locale: getLocaleCode(),
+                tenant: getTenantId(),
+                editor: DEFAULT_EDITOR,
+                category: category.slug,
+                title,
+                path: pagePath,
+                version,
+                status,
+                locked,
+                publishedOn,
+                createdFrom: null,
+                settings: {
+                    ...settings,
+                    general: {
+                        ...settings.general,
+                        tags: settings.general?.tags || undefined
+                    },
+                    social: {
+                        ...settings.social,
+                        meta: settings.social?.meta || []
+                    },
+                    seo: {
+                        ...settings.seo,
+                        meta: settings.seo?.meta || []
+                    }
+                },
+                createdOn: getDate(input.createdOn, currentDateTime),
+                savedOn: getDate(input.createdOn, currentDateTime),
+                createdBy: getIdentity(input.createdBy, currentIdentity),
+                ownedBy: getIdentity(input.createdBy, currentIdentity),
+                content: input.content || PageContent.createEmpty().getValue(),
+                webinyVersion: context.WEBINY_VERSION
+            };
+
+            try {
+                await onPageBeforeCreate.publish({
+                    page,
+                    meta
+                });
+
+                await storageOperations.pages.create({
+                    input: {
+                        slug: categorySlug
                     },
                     page: await compressPage(page)
                 });
diff --git a/packages/api-page-builder/src/graphql/crud/utils/formatDate.ts b/packages/api-page-builder/src/graphql/crud/utils/formatDate.ts
new file mode 100644
index 00000000000..b3063eb352f
--- /dev/null
+++ b/packages/api-page-builder/src/graphql/crud/utils/formatDate.ts
@@ -0,0 +1,12 @@
+/**
+ * Should not be used by users as method is prone to breaking changes.
+ * @internal
+ */
+export const formatDate = (date?: Date | string | null): string | null => {
+    if (!date) {
+        return null;
+    } else if (date instanceof Date) {
+        return date.toISOString();
+    }
+    return new Date(date).toISOString();
+};
diff --git a/packages/api-page-builder/src/graphql/crud/utils/getDate.ts b/packages/api-page-builder/src/graphql/crud/utils/getDate.ts
new file mode 100644
index 00000000000..2d4cd08fc5f
--- /dev/null
+++ b/packages/api-page-builder/src/graphql/crud/utils/getDate.ts
@@ -0,0 +1,14 @@
+import { formatDate } from "./formatDate";
+
+export const getDate = <T extends string | null = string | null>(
+    input?: Date | string | null,
+    defaultValue?: Date | string | null
+): T => {
+    if (!input) {
+        return formatDate(defaultValue) as T;
+    }
+    if (input instanceof Date) {
+        return formatDate(input) as T;
+    }
+    return formatDate(new Date(input)) as T;
+};
diff --git a/packages/api-page-builder/src/graphql/crud/utils/getIdentity.ts b/packages/api-page-builder/src/graphql/crud/utils/getIdentity.ts
new file mode 100644
index 00000000000..994e94d0662
--- /dev/null
+++ b/packages/api-page-builder/src/graphql/crud/utils/getIdentity.ts
@@ -0,0 +1,16 @@
+import { SecurityIdentity } from "@webiny/api-security/types";
+
+export const getIdentity = <T extends SecurityIdentity | null>(
+    input: SecurityIdentity | null | undefined,
+    defaultValue: T | null = null
+): T => {
+    const identity = input?.id && input?.displayName && input?.type ? input : defaultValue;
+    if (!identity) {
+        return null as T;
+    }
+    return {
+        id: identity.id,
+        displayName: identity.displayName,
+        type: identity.type
+    } as T;
+};
diff --git a/packages/api-page-builder/src/graphql/graphql/base.gql.ts b/packages/api-page-builder/src/graphql/graphql/base.gql.ts
index ad5016f52fc..0175ff29e75 100644
--- a/packages/api-page-builder/src/graphql/graphql/base.gql.ts
+++ b/packages/api-page-builder/src/graphql/graphql/base.gql.ts
@@ -23,6 +23,12 @@ export const createBaseGraphQL = (): GraphQLSchemaPlugin => {
                     type: String
                 }
 
+                input PbIdentityInput {
+                    id: ID!
+                    displayName: String!
+                    type: String!
+                }
+
                 type PbError {
                     code: String
                     message: String
diff --git a/packages/api-page-builder/src/graphql/graphql/categories.gql.ts b/packages/api-page-builder/src/graphql/graphql/categories.gql.ts
index 7f17ec6db47..a729d829931 100644
--- a/packages/api-page-builder/src/graphql/graphql/categories.gql.ts
+++ b/packages/api-page-builder/src/graphql/graphql/categories.gql.ts
@@ -29,6 +29,8 @@ export const createCategoryGraphQL = (): GraphQLSchemaPlugin<PbContext> => {
                     slug: String!
                     url: String!
                     layout: String!
+                    createdBy: PbIdentityInput
+                    createdOn: DateTime
                 }
 
                 # Response types
diff --git a/packages/api-page-builder/src/graphql/graphql/menus.gql.ts b/packages/api-page-builder/src/graphql/graphql/menus.gql.ts
index d37aceaaa01..c9c98e21124 100644
--- a/packages/api-page-builder/src/graphql/graphql/menus.gql.ts
+++ b/packages/api-page-builder/src/graphql/graphql/menus.gql.ts
@@ -23,6 +23,8 @@ export const createMenuGraphQL = (): GraphQLSchemaPlugin<PbContext> => {
                     slug: String!
                     description: String
                     items: [JSON]
+                    createdBy: PbIdentityInput
+                    createdOn: DateTime
                 }
 
                 # Response types
diff --git a/packages/api-page-builder/src/graphql/graphql/pages.gql.ts b/packages/api-page-builder/src/graphql/graphql/pages.gql.ts
index 5f910fdafab..1c938afc965 100644
--- a/packages/api-page-builder/src/graphql/graphql/pages.gql.ts
+++ b/packages/api-page-builder/src/graphql/graphql/pages.gql.ts
@@ -109,6 +109,24 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin<PbContext> => {
                     dataBindings: [DataBindingInput!]
                 }
 
+                input PbCreatePageV2Input {
+                    id: ID
+                    pid: ID
+                    category: ID
+                    title: String
+                    version: Int
+                    path: String
+                    content: JSON
+                    savedOn: DateTime
+                    status: String
+                    publishedOn: DateTime
+                    settings: PbPageSettingsInput
+                    createdOn: DateTime
+                    createdBy: PbIdentityInput
+                    dataSources: [DataSourceInput!]
+                    dataBindings: [DataBindingInput!]
+                }
+
                 input PbPageSettingsInput {
                     _empty: String
                 }
@@ -242,6 +260,8 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin<PbContext> => {
                 extend type PbMutation {
                     createPage(from: ID, category: String, meta: JSON): PbPageResponse
 
+                    createPageV2(data: PbCreatePageV2Input!): PbPageResponse
+
                     # Update page by given ID.
                     updatePage(id: ID!, data: PbUpdatePageInput!): PbPageResponse
 
@@ -457,6 +477,12 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin<PbContext> => {
                             return context.pageBuilder.createPage(category as string, meta);
                         });
                     },
+                    createPageV2: async (_, args: any, context) => {
+                        return resolve(() => {
+                            const { data } = args;
+                            return context.pageBuilder.createPageV2(data);
+                        });
+                    },
 
                     deletePage: async (_, args: any, context) => {
                         return resolve(async () => {
diff --git a/packages/api-page-builder/src/graphql/types.ts b/packages/api-page-builder/src/graphql/types.ts
index fc2ad2e23c7..a61f56163ae 100644
--- a/packages/api-page-builder/src/graphql/types.ts
+++ b/packages/api-page-builder/src/graphql/types.ts
@@ -9,6 +9,7 @@ import { Context as BaseContext } from "@webiny/handler/types";
 import {
     BlockCategory,
     Category,
+    CreatedBy,
     DefaultSettings,
     DynamicDocument,
     Menu,
@@ -17,6 +18,7 @@ import {
     PageElement,
     PageSettings,
     PageSpecialType,
+    PageStatus,
     PageTemplate,
     PageTemplateInput,
     Settings,
@@ -32,8 +34,10 @@ export interface ListPagesParamsWhere {
     category?: string;
     status?: string;
     tags?: { query: string[]; rule?: "any" | "all" };
+
     [key: string]: any;
 }
+
 export interface ListPagesParams {
     limit?: number;
     after?: string | null;
@@ -75,6 +79,7 @@ export interface OnPageBeforeCreateTopicParams<TPage extends Page = Page> {
     page: TPage;
     meta?: Record<string, any>;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -82,6 +87,7 @@ export interface OnPageAfterCreateTopicParams<TPage extends Page = Page> {
     page: TPage;
     meta?: Record<string, any>;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -90,6 +96,7 @@ export interface OnPageBeforeUpdateTopicParams<TPage extends Page = Page> {
     page: TPage;
     input: Record<string, any>;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -98,6 +105,7 @@ export interface OnPageAfterUpdateTopicParams<TPage extends Page = Page> {
     page: TPage;
     input: Record<string, any>;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -105,6 +113,7 @@ export interface OnPageBeforeCreateFromTopicParams<TPage extends Page = Page> {
     original: TPage;
     page: TPage;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -112,6 +121,7 @@ export interface OnPageAfterCreateFromTopicParams<TPage extends Page = Page> {
     original: TPage;
     page: TPage;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -121,6 +131,7 @@ export interface OnPageBeforeDeleteTopicParams<TPage extends Page = Page> {
     publishedPage: TPage | null;
     deleteMethod: "deleteAll" | "delete";
 }
+
 /**
  * @category Lifecycle events
  */
@@ -130,6 +141,7 @@ export interface OnPageAfterDeleteTopicParams<TPage extends Page = Page> {
     publishedPage: TPage | null;
     deleteMethod: "deleteAll" | "delete";
 }
+
 /**
  * @category Lifecycle events
  */
@@ -138,6 +150,7 @@ export interface OnPageBeforePublishTopicParams<TPage extends Page = Page> {
     latestPage: TPage;
     publishedPage: TPage | null;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -146,6 +159,7 @@ export interface OnPageAfterPublishTopicParams<TPage extends Page = Page> {
     latestPage: TPage;
     publishedPage: TPage | null;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -153,6 +167,7 @@ export interface OnPageBeforeUnpublishTopicParams<TPage extends Page = Page> {
     page: TPage;
     latestPage: TPage;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -193,34 +208,54 @@ export interface PageElementProcessor {
  */
 export interface PagesCrud {
     addPageElementProcessor(processor: PageElementProcessor): void;
+
     processPageContent(content: Page): Promise<Page>;
+
     getPage<TPage extends Page = Page>(id: string, options?: GetPagesOptions): Promise<TPage>;
+
     listLatestPages<TPage extends Page = Page>(
         args: ListPagesParams,
         options?: ListLatestPagesOptions
     ): Promise<[TPage[], ListMeta]>;
+
     listPublishedPages<TPage extends Page = Page>(
         args: ListPagesParams
     ): Promise<[TPage[], ListMeta]>;
+
     listPagesTags(args: { search: { query: string } }): Promise<string[]>;
+
     getPublishedPageById<TPage extends Page = Page>(args: {
         id: string;
         preview?: boolean;
     }): Promise<TPage>;
+
     getPublishedPageByPath<TPage extends Page = Page>(args: { path: string }): Promise<TPage>;
+
     listPageRevisions<TPage extends Page = Page>(id: string): Promise<TPage[]>;
+
     createPage<TPage extends Page = Page>(
         category: string,
         meta?: Record<string, any>
     ): Promise<TPage>;
+
+    createPageV2<TPage extends Page = Page>(
+        data: PbCreatePageV2Input,
+        meta?: Record<string, any>
+    ): Promise<TPage>;
+
     createPageFrom<TPage extends Page = Page>(
         page: string,
         meta?: Record<string, any>
     ): Promise<TPage>;
+
     updatePage<TPage extends Page = Page>(id: string, data: PbUpdatePageInput): Promise<TPage>;
+
     deletePage<TPage extends Page = Page>(id: string): Promise<[TPage, TPage]>;
+
     publishPage<TPage extends Page = Page>(id: string): Promise<TPage>;
+
     unpublishPage<TPage extends Page = Page>(id: string): Promise<TPage>;
+
     prerendering: {
         render(args: RenderParams): Promise<void>;
         flush(args: FlushParams): Promise<void>;
@@ -249,12 +284,14 @@ export interface ListPageElementsParams {
 export interface OnPageElementBeforeCreateTopicParams {
     pageElement: PageElement;
 }
+
 /**
  * @category Lifecycle events
  */
 export interface OnPageElementAfterCreateTopicParams {
     pageElement: PageElement;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -262,6 +299,7 @@ export interface OnPageElementBeforeUpdateTopicParams {
     original: PageElement;
     pageElement: PageElement;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -269,12 +307,14 @@ export interface OnPageElementAfterUpdateTopicParams {
     original: PageElement;
     pageElement: PageElement;
 }
+
 /**
  * @category Lifecycle events
  */
 export interface OnPageElementBeforeDeleteTopicParams {
     pageElement: PageElement;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -287,10 +327,15 @@ export interface OnPageElementAfterDeleteTopicParams {
  */
 export interface PageElementsCrud {
     getPageElement(id: string): Promise<PageElement | null>;
+
     listPageElements(params?: ListPageElementsParams): Promise<PageElement[]>;
+
     createPageElement(data: Record<string, any>): Promise<PageElement>;
+
     updatePageElement(id: string, data: Record<string, any>): Promise<PageElement>;
+
     deletePageElement(id: string): Promise<void>;
+
     /**
      * Lifecycle events
      */
@@ -308,12 +353,14 @@ export interface PageElementsCrud {
 export interface OnCategoryBeforeCreateTopicParams {
     category: Category;
 }
+
 /**
  * @category Lifecycle events
  */
 export interface OnCategoryAfterCreateTopicParams {
     category: Category;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -321,6 +368,7 @@ export interface OnCategoryBeforeUpdateTopicParams {
     original: Category;
     category: Category;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -328,12 +376,14 @@ export interface OnCategoryAfterUpdateTopicParams {
     original: Category;
     category: Category;
 }
+
 /**
  * @category Lifecycle events
  */
 export interface OnCategoryBeforeDeleteTopicParams {
     category: Category;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -346,10 +396,15 @@ export interface OnCategoryAfterDeleteTopicParams {
  */
 export interface CategoriesCrud {
     getCategory(slug: string, options?: { auth: boolean }): Promise<Category | null>;
+
     listCategories(): Promise<Category[]>;
+
     createCategory(data: PbCategoryInput): Promise<Category>;
+
     updateCategory(slug: string, data: PbCategoryInput): Promise<Category>;
+
     deleteCategory(slug: string): Promise<Category>;
+
     onCategoryBeforeCreate: Topic<OnCategoryBeforeCreateTopicParams>;
     onCategoryAfterCreate: Topic<OnCategoryAfterCreateTopicParams>;
     onCategoryBeforeUpdate: Topic<OnCategoryBeforeUpdateTopicParams>;
@@ -373,6 +428,7 @@ export interface OnMenuBeforeCreateTopicParams {
     menu: Menu;
     input: Record<string, any>;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -380,6 +436,7 @@ export interface OnMenuAfterCreateTopicParams {
     menu: Menu;
     input: Record<string, any>;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -387,6 +444,7 @@ export interface OnMenuBeforeUpdateTopicParams {
     original: Menu;
     menu: Menu;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -394,12 +452,14 @@ export interface OnMenuAfterUpdateTopicParams {
     original: Menu;
     menu: Menu;
 }
+
 /**
  * @category Lifecycle events
  */
 export interface OnMenuBeforeDeleteTopicParams {
     menu: Menu;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -412,17 +472,26 @@ interface CreateMenuInput {
     slug: string;
     description: string;
     items: any[];
+    createdOn?: Date | string;
+    createdBy?: CreatedBy;
 }
+
 /**
  * @category Menu
  */
 export interface MenusCrud {
     getMenu(slug: string, options?: MenuGetOptions): Promise<Menu | null>;
+
     getPublicMenu(slug: string): Promise<Menu>;
+
     listMenus(params?: ListMenuParams): Promise<Menu[]>;
+
     createMenu(data: CreateMenuInput): Promise<Menu>;
+
     updateMenu(slug: string, data: Record<string, any>): Promise<Menu>;
+
     deleteMenu(slug: string): Promise<Menu>;
+
     onMenuBeforeCreate: Topic<OnMenuBeforeCreateTopicParams>;
     onMenuAfterCreate: Topic<OnMenuAfterCreateTopicParams>;
     onMenuBeforeUpdate: Topic<OnMenuBeforeUpdateTopicParams>;
@@ -444,6 +513,7 @@ export interface SettingsUpdateTopicMetaParams {
         pages: [PageSpecialType, string | null | undefined, string, Page][];
     };
 }
+
 /**
  * @category Lifecycle events
  */
@@ -452,6 +522,7 @@ export interface OnSettingsBeforeUpdateTopicParams {
     settings: Settings;
     meta: SettingsUpdateTopicMetaParams;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -484,20 +555,26 @@ export interface SettingsCrud {
 export interface OnSystemBeforeInstallTopicParams {
     tenant: string;
 }
+
 /**
  * @category Lifecycle events
  */
 export interface OnSystemAfterInstallTopicParams {
     tenant: string;
 }
+
 /**
  * @category System
  */
 export interface SystemCrud {
     getSystem: () => Promise<System | null>;
+
     getSystemVersion(): Promise<string | null>;
+
     setSystemVersion(version: string): Promise<void>;
+
     installSystem(args: { name: string; insertDemoData: boolean }): Promise<void>;
+
     /**
      * Lifecycle events
      */
@@ -518,12 +595,14 @@ export interface PbBlockCategoryInput {
 export interface OnBeforeBlockCategoryCreateTopicParams {
     blockCategory: BlockCategory;
 }
+
 /**
  * @category Lifecycle events
  */
 export interface OnAfterBlockCategoryCreateTopicParams {
     blockCategory: BlockCategory;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -531,6 +610,7 @@ export interface OnBeforeBlockCategoryUpdateTopicParams {
     original: BlockCategory;
     blockCategory: BlockCategory;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -538,12 +618,14 @@ export interface OnAfterBlockCategoryUpdateTopicParams {
     original: BlockCategory;
     blockCategory: BlockCategory;
 }
+
 /**
  * @category Lifecycle events
  */
 export interface OnBeforeBlockCategoryDeleteTopicParams {
     blockCategory: BlockCategory;
 }
+
 /**
  * @category Lifecycle events
  */
@@ -556,10 +638,15 @@ export interface OnAfterBlockCategoryDeleteTopicParams {
  */
 export interface BlockCategoriesCrud {
     getBlockCategory(slug: string, options?: { auth: boolean }): Promise<BlockCategory | null>;
+
     listBlockCategories(): Promise<BlockCategory[]>;
+
     createBlockCategory(data: PbBlockCategoryInput): Promise<BlockCategory>;
+
     updateBlockCategory(slug: string, data: PbBlockCategoryInput): Promise<BlockCategory>;
+
     deleteBlockCategory(slug: string): Promise<BlockCategory>;
+
     /**
      * Lifecycle events
      */
@@ -577,6 +664,7 @@ export interface ListPageBlocksParams {
         blockCategory?: string;
     };
 }
+
 /**
  * @category Lifecycle events
  */
@@ -633,10 +721,15 @@ export type PageBlockUpdateInput = Partial<PageBlockCreateInput>;
  */
 export interface PageBlocksCrud {
     getPageBlock(id: string): Promise<PageBlock | null>;
+
     listPageBlocks(params?: ListPageBlocksParams): Promise<PageBlock[]>;
+
     createPageBlock(data: PageBlockCreateInput): Promise<PageBlock>;
+
     updatePageBlock(id: string, data: PageBlockUpdateInput): Promise<PageBlock>;
+
     deletePageBlock(id: string): Promise<boolean>;
+
     resolvePageBlocks(content: Record<string, any> | null): Promise<any>;
 
     /**
@@ -653,6 +746,7 @@ export interface PageBlocksCrud {
 export interface ListPageTemplatesParams {
     sort?: string[];
 }
+
 /**
  * @category Lifecycle events
  */
@@ -742,17 +836,25 @@ export interface PageTemplatesCrud {
             auth: boolean;
         }
     ): Promise<PageTemplate | null>;
+
     listPageTemplates(params?: ListPageTemplatesParams): Promise<PageTemplate[]>;
+
     createPageTemplate(data: PageTemplateInput): Promise<PageTemplate>;
+
     createPageFromTemplate(data: CreatePageFromTemplateParams): Promise<Page>;
+
     createTemplateFromPage(
         pageId: string,
         data: Pick<PageTemplate, "title" | "description" | "slug">
     ): Promise<PageTemplate>;
+
     // Copy relevant data from page template to page instance, by reference.
     copyTemplateDataToPage(template: PageTemplate, page: Page): void;
+
     updatePageTemplate(id: string, data: Record<string, any>): Promise<PageTemplate>;
+
     deletePageTemplate(id: string): Promise<PageTemplate>;
+
     resolvePageTemplate(content: PageContentWithTemplate): Promise<any>;
 
     /**
@@ -878,6 +980,7 @@ export interface FlushParams {
 
 export interface PrerenderingHandlers {
     render(args: RenderParams): Promise<void>;
+
     flush(args: FlushParams): Promise<void>;
 }
 
@@ -886,6 +989,8 @@ export interface PbCategoryInput {
     slug: string;
     url: string;
     layout: string;
+    createdOn?: Date | string;
+    createdBy?: CreatedBy;
 }
 
 export interface PbUpdatePageInput extends DynamicDocument {
@@ -895,3 +1000,19 @@ export interface PbUpdatePageInput extends DynamicDocument {
     settings?: PageSettings;
     content?: Record<string, any> | null;
 }
+
+export interface PbCreatePageV2Input extends DynamicDocument {
+    title?: string;
+    category?: string;
+    path?: string;
+    status?: PageStatus;
+    publishedOn?: Date | string;
+    settings?: PageSettings;
+    content?: Record<string, any> | null;
+    version?: number;
+    id?: string;
+    pid?: string;
+    createdOn?: Date | string;
+    createdBy?: CreatedBy;
+    ownedBy?: CreatedBy;
+}
diff --git a/packages/api-page-builder/src/prerendering/hooks/afterSettingsUpdate.ts b/packages/api-page-builder/src/prerendering/hooks/afterSettingsUpdate.ts
index 3f22b5db150..9a01f0a8ae7 100644
--- a/packages/api-page-builder/src/prerendering/hooks/afterSettingsUpdate.ts
+++ b/packages/api-page-builder/src/prerendering/hooks/afterSettingsUpdate.ts
@@ -1,63 +1,16 @@
-import { PathItem, PbContext } from "~/graphql/types";
+import { PbContext } from "~/graphql/types";
 import { ContextPlugin } from "@webiny/api";
 
 export default () => {
     return new ContextPlugin<PbContext>(async ({ pageBuilder }) => {
-        pageBuilder.onSettingsAfterUpdate.subscribe(async params => {
-            const { settings, meta } = params;
-            if (!settings) {
-                return;
-            }
-
-            /**
-             * If a change on pages settings (home, notFound) has been made, let's rerender accordingly.
-             */
-            const toRender: PathItem[] = [];
-
-            for (let i = 0; i < meta.diff.pages.length; i++) {
-                const [type, prevPageId, , page] = meta.diff.pages[i];
-                switch (type) {
-                    case "home":
-                        toRender.push({ path: "/" });
-                        break;
-                    case "notFound":
-                        // Render the new "not found" page and store it into the NOT_FOUND_FOLDER.
-                        toRender.push({
-                            path: page.path,
-                            tags: [{ key: "notFoundPage", value: true }]
-                        });
-
-                        if (prevPageId) {
-                            // Render the old "not found" page, to remove any notion of the "not found" concept
-                            // from the snapshot, as well as the PS#RENDER record in the database.
-                            const prevPage = await pageBuilder.getPublishedPageById({
-                                id: prevPageId
-                            });
-
-                            toRender.push({ path: prevPage.path });
-                        }
-
-                        break;
-                }
-            }
-
-            // Render homepage/not-found page
-            if (toRender.length > 0) {
-                await pageBuilder.prerendering.render({ paths: toRender });
-            }
-
-            /**
-             * TODO: right now, on each update of settings, we trigger a complete site rebuild.
-             * This is from ideal, and we need to implement better checks if full rerender is necessary.
-             * Why full site rerender? Settings contain logo, favicon, site title, social stuff, and that's
-             * used on all pages.
-             */
+        pageBuilder.onSettingsAfterUpdate.subscribe(async () => {
+            // On every update of settings, we trigger a full website prerendering.
+            // Might not be ideal for large websites, but it's a simple solution for now.
             await pageBuilder.prerendering.render({
                 // This flag is for backwards compatibility with the original custom queue implementation
                 // using the "cron job" type of Lambda worker, executed periodically.
                 queue: true,
-                // We want to rerender everything, but exclude homepage/not-found page, if they were changed.
-                paths: [{ path: "*", exclude: toRender.map(task => task.path) }]
+                paths: [{ path: "*" }]
             });
         });
     });

From 8ef22d350742cb0b25d1e946fe0fa76a87f93dbd Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Tue, 1 Apr 2025 11:37:13 +0200
Subject: [PATCH 39/52] fix(api-prerendering-service): add groupId to render
 event

---
 .../api-page-builder/src/prerendering/prerenderingHandlers.ts | 1 +
 .../api-prerendering-service-aws/src/render/subscriber.ts     | 2 +-
 packages/api-prerendering-service/src/render/index.ts         | 4 +++-
 packages/api-prerendering-service/src/types.ts                | 2 ++
 4 files changed, 7 insertions(+), 2 deletions(-)

diff --git a/packages/api-page-builder/src/prerendering/prerenderingHandlers.ts b/packages/api-page-builder/src/prerendering/prerenderingHandlers.ts
index b53958d60ec..7fd93b47026 100644
--- a/packages/api-page-builder/src/prerendering/prerenderingHandlers.ts
+++ b/packages/api-page-builder/src/prerendering/prerenderingHandlers.ts
@@ -20,6 +20,7 @@ export const prerenderingHandlers = new ContextPlugin<PbContext>(context => {
                 const render = paths.map<RenderEvent>(item => ({
                     ...item,
                     tenant,
+                    groupId: tenant,
                     locale: locale.code
                 }));
 
diff --git a/packages/api-prerendering-service-aws/src/render/subscriber.ts b/packages/api-prerendering-service-aws/src/render/subscriber.ts
index 2182ee27ffc..aff36e40693 100644
--- a/packages/api-prerendering-service-aws/src/render/subscriber.ts
+++ b/packages/api-prerendering-service-aws/src/render/subscriber.ts
@@ -114,7 +114,7 @@ export default (params: HandlerConfig) => {
              * the database. This way we are sure that we don't store obsolete infrastructure information.
              */
             toRender.push({
-                groupId: render.tenant,
+                groupId: render.groupId ?? render.tenant,
                 body: render
             });
         }
diff --git a/packages/api-prerendering-service/src/render/index.ts b/packages/api-prerendering-service/src/render/index.ts
index a7de5ffbfcc..8981d291a77 100644
--- a/packages/api-prerendering-service/src/render/index.ts
+++ b/packages/api-prerendering-service/src/render/index.ts
@@ -56,7 +56,8 @@ export default (params: RenderParams) => {
             const settings = await storageOperations.getSettings();
 
             for (const args of handlerArgs) {
-                const { tenant, path, locale } = args;
+                const { tenant, path, locale, groupId } = args;
+                console.log("Rendering item", args);
 
                 const bucketRoot = isMultiTenant ? tenant : "";
 
@@ -118,6 +119,7 @@ export default (params: RenderParams) => {
                     tenant,
                     path,
                     locale,
+                    groupId: groupId ?? tenant,
                     tags: args.tags,
                     files: files.map(item => omit(item, ["body"]))
                 };
diff --git a/packages/api-prerendering-service/src/types.ts b/packages/api-prerendering-service/src/types.ts
index d0fd251fa9b..ece29e4f9d2 100644
--- a/packages/api-prerendering-service/src/types.ts
+++ b/packages/api-prerendering-service/src/types.ts
@@ -36,6 +36,7 @@ export interface Render {
     path: string;
     tenant: string;
     locale: string;
+    groupId: string;
     tags?: Tag[];
     files: {
         name: string;
@@ -171,6 +172,7 @@ export interface RenderEvent {
     path: string;
     tenant: string;
     locale: string;
+    groupId: string;
     exclude?: string[];
     tags?: Tag[];
 }

From 2f2dbebb4bbf728ced489516d9a64296e0e27e23 Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Wed, 2 Apr 2025 11:59:54 +0200
Subject: [PATCH 40/52] fix(app-page-builder): add missing render plugin
 loaders

---
 .../src/admin/plugins/routes.tsx              | 30 +++++++++++--------
 1 file changed, 18 insertions(+), 12 deletions(-)

diff --git a/packages/app-page-builder/src/admin/plugins/routes.tsx b/packages/app-page-builder/src/admin/plugins/routes.tsx
index 8cd18ad6eb2..dc3339deb89 100644
--- a/packages/app-page-builder/src/admin/plugins/routes.tsx
+++ b/packages/app-page-builder/src/admin/plugins/routes.tsx
@@ -90,10 +90,12 @@ const plugins: RoutePlugin[] = [
                 element={
                     <SecureRoute permission={ROLE_PB_PAGES}>
                         <EditorPluginsLoader>
-                            <Helmet title={"Page Builder - Edit page"} />
-                            <CompositionScope name={"pb.pageEditor"}>
-                                <PageEditor />
-                            </CompositionScope>
+                            <RenderPluginsLoader>
+                                <Helmet title={"Page Builder - Edit page"} />
+                                <CompositionScope name={"pb.pageEditor"}>
+                                    <PageEditor />
+                                </CompositionScope>
+                            </RenderPluginsLoader>
                         </EditorPluginsLoader>
                     </SecureRoute>
                 }
@@ -128,10 +130,12 @@ const plugins: RoutePlugin[] = [
                 element={
                     <SecureRoute permission={ROLE_PB_TEMPLATE}>
                         <EditorPluginsLoader>
-                            <Helmet title={"Page Builder - Edit template"} />
-                            <CompositionScope name={"pb.templateEditor"}>
-                                <TemplateEditor />
-                            </CompositionScope>
+                            <RenderPluginsLoader>
+                                <Helmet title={"Page Builder - Edit template"} />
+                                <CompositionScope name={"pb.templateEditor"}>
+                                    <TemplateEditor />
+                                </CompositionScope>
+                            </RenderPluginsLoader>
                         </EditorPluginsLoader>
                     </SecureRoute>
                 }
@@ -183,10 +187,12 @@ const plugins: RoutePlugin[] = [
                 element={
                     <SecureRoute permission={ROLE_PB_PAGES}>
                         <EditorPluginsLoader>
-                            <Helmet title={"Page Builder - Edit block"} />
-                            <CompositionScope name={"pb.blockEditor"}>
-                                <BlockEditor />
-                            </CompositionScope>
+                            <RenderPluginsLoader>
+                                <Helmet title={"Page Builder - Edit block"} />
+                                <CompositionScope name={"pb.blockEditor"}>
+                                    <BlockEditor />
+                                </CompositionScope>
+                            </RenderPluginsLoader>
                         </EditorPluginsLoader>
                     </SecureRoute>
                 }

From ac0a3bb8a57c0ba8962f7faaf8052e52a3e4822b Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Tue, 8 Apr 2025 12:32:01 +0200
Subject: [PATCH 41/52] fix(handler-graphql): add a reusable Error and
 BooleanResponse graphql types

---
 packages/handler-graphql/src/createGraphQLSchema.ts | 12 ++++++++++++
 1 file changed, 12 insertions(+)

diff --git a/packages/handler-graphql/src/createGraphQLSchema.ts b/packages/handler-graphql/src/createGraphQLSchema.ts
index 5bb8045bfef..3d9d0df6ac0 100644
--- a/packages/handler-graphql/src/createGraphQLSchema.ts
+++ b/packages/handler-graphql/src/createGraphQLSchema.ts
@@ -45,6 +45,18 @@ export const createGraphQLSchema = (context: Context) => {
             # as a way to tell the Prerendering Service whether the GraphQL query needs to be
             # cached or not.
             directive @ps(cache: Boolean) on QUERY
+
+            type Error {
+                code: String
+                message: String
+                data: JSON
+                stack: String
+            }
+
+            type BooleanResponse {
+                data: Boolean
+                error: Error
+            }
         `
     ];
 

From 72829f8f2e1d979ab8ad14ac4806f032521ff950 Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Tue, 8 Apr 2025 12:33:17 +0200
Subject: [PATCH 42/52] fix(api-page-builder): add missing translations use
 cases

---
 ...eleteTranslatableCollectionUseCase.test.ts |  38 +++++
 .../DeleteTranslatedCollectionUseCase.test.ts | 131 ++++++++++++++++++
 .../src/translations/index.ts                 |   2 +
 .../graphql/resolvers.ts                      |  17 +++
 .../translatableCollection/graphql/schema.ts  |   4 +-
 .../DeleteTranslatableCollectionRepository.ts |  32 +++++
 ...GetTranslatableCollectionByIdRepository.ts |  18 ++-
 .../DeleteTranslatableCollectionUseCase.ts    |  20 +++
 .../translatedCollection/graphql/resolvers.ts |  22 ++-
 .../translatedCollection/graphql/schema.ts    |   2 +
 .../DeleteTranslatedCollectionRepository.ts   |  40 ++++++
 .../GetTranslatedCollectionRepository.ts      |  20 +--
 .../DeleteTranslatedCollectionUseCase.ts      |  21 +++
 13 files changed, 349 insertions(+), 18 deletions(-)
 create mode 100644 packages/api-page-builder/__tests__/translations/translatableCollection/DeleteTranslatableCollectionUseCase.test.ts
 create mode 100644 packages/api-page-builder/__tests__/translations/translatedCollection/DeleteTranslatedCollectionUseCase.test.ts
 create mode 100644 packages/api-page-builder/src/translations/translatableCollection/repository/DeleteTranslatableCollectionRepository.ts
 create mode 100644 packages/api-page-builder/src/translations/translatableCollection/useCases/DeleteTranslatableCollectionUseCase.ts
 create mode 100644 packages/api-page-builder/src/translations/translatedCollection/repository/DeleteTranslatedCollectionRepository.ts
 create mode 100644 packages/api-page-builder/src/translations/translatedCollection/useCases/DeleteTranslatedCollectionUseCase.ts

diff --git a/packages/api-page-builder/__tests__/translations/translatableCollection/DeleteTranslatableCollectionUseCase.test.ts b/packages/api-page-builder/__tests__/translations/translatableCollection/DeleteTranslatableCollectionUseCase.test.ts
new file mode 100644
index 00000000000..b269ea28818
--- /dev/null
+++ b/packages/api-page-builder/__tests__/translations/translatableCollection/DeleteTranslatableCollectionUseCase.test.ts
@@ -0,0 +1,38 @@
+import { useHandler } from "~tests/translations/useHandler";
+import {
+    DeleteTranslatableCollectionUseCase,
+    GetTranslatableCollectionUseCase,
+    SaveTranslatableCollectionUseCase
+} from "~/translations";
+
+describe("DeleteTranslatableCollectionUseCase", () => {
+    it("should delete a collection", async () => {
+        const { handler } = useHandler();
+        const context = await handler();
+
+        // Setup
+        const saveTranslatableCollection = new SaveTranslatableCollectionUseCase(context);
+        const newCollection = await saveTranslatableCollection.execute({
+            collectionId: "collection:1",
+            items: [
+                { itemId: "element:1", value: "Value 1" },
+                { itemId: "element:2", value: "Value 2" }
+            ]
+        });
+
+        const getTranslatableCollection = new GetTranslatableCollectionUseCase(context);
+        const collection = await getTranslatableCollection.execute(newCollection.getCollectionId());
+
+        expect(collection).toBeTruthy();
+        expect(collection!.getCollectionId()).toEqual(newCollection.getCollectionId());
+
+        // Test
+        const deleteTranslatableCollection = new DeleteTranslatableCollectionUseCase(context);
+        await deleteTranslatableCollection.execute({
+            collectionId: "collection:1"
+        });
+
+        const checkCollection = await getTranslatableCollection.execute("collection:1");
+        expect(checkCollection).toBeUndefined();
+    });
+});
diff --git a/packages/api-page-builder/__tests__/translations/translatedCollection/DeleteTranslatedCollectionUseCase.test.ts b/packages/api-page-builder/__tests__/translations/translatedCollection/DeleteTranslatedCollectionUseCase.test.ts
new file mode 100644
index 00000000000..d0192645444
--- /dev/null
+++ b/packages/api-page-builder/__tests__/translations/translatedCollection/DeleteTranslatedCollectionUseCase.test.ts
@@ -0,0 +1,131 @@
+import { useHandler } from "~tests/translations/useHandler";
+import {
+    SaveTranslatableCollectionUseCase,
+    SaveTranslatableCollectionParams,
+    SaveTranslatedCollectionUseCase,
+    DeleteTranslatedCollectionUseCase,
+    GetTranslatedCollectionUseCase
+} from "~/translations";
+import { PbContext } from "~/graphql/types";
+
+const createTranslatableCollection = async (
+    context: PbContext,
+    params: SaveTranslatableCollectionParams
+) => {
+    const saveCollection = new SaveTranslatableCollectionUseCase(context);
+    await saveCollection.execute(params);
+};
+
+describe("DeleteTranslatedCollectionUseCase", () => {
+    it("should delete an entire collection with all translations", async () => {
+        const { handler } = useHandler();
+        const context = await handler();
+
+        // Setup
+        await createTranslatableCollection(context, {
+            collectionId: "collection:1",
+            items: [
+                { itemId: "element:1", value: "Value 1" },
+                { itemId: "element:2", value: "Value 2" },
+                { itemId: "element:3", value: "Value 3" }
+            ]
+        });
+
+        const saveTranslatedCollection = new SaveTranslatedCollectionUseCase(context);
+        await saveTranslatedCollection.execute({
+            collectionId: "collection:1",
+            languageCode: "en",
+            items: [
+                { itemId: "element:1", value: "Translated Value 1 EN" },
+                { itemId: "element:2", value: "Translated Value 2 EN" }
+            ]
+        });
+
+        await saveTranslatedCollection.execute({
+            collectionId: "collection:1",
+            languageCode: "de",
+            items: [
+                { itemId: "element:1", value: "Translated Value 1 DE" },
+                { itemId: "element:2", value: "Translated Value 2 DE" }
+            ]
+        });
+
+        // Test
+        const deleteTranslatedCollection = new DeleteTranslatedCollectionUseCase(context);
+        await deleteTranslatedCollection.execute({ collectionId: "collection:1" });
+
+        const getTranslatedCollection = new GetTranslatedCollectionUseCase(context);
+
+        await expect(
+            getTranslatedCollection.execute({
+                collectionId: "collection:1",
+                languageCode: "en"
+            })
+        ).rejects.toThrow("not found");
+
+        await expect(
+            getTranslatedCollection.execute({
+                collectionId: "collection:1",
+                languageCode: "de"
+            })
+        ).rejects.toThrow("not found");
+    });
+
+    it("should delete a collection for a given language", async () => {
+        const { handler } = useHandler();
+        const context = await handler();
+
+        // Setup
+        await createTranslatableCollection(context, {
+            collectionId: "collection:1",
+            items: [
+                { itemId: "element:1", value: "Value 1" },
+                { itemId: "element:2", value: "Value 2" },
+                { itemId: "element:3", value: "Value 3" }
+            ]
+        });
+
+        const saveTranslatedCollection = new SaveTranslatedCollectionUseCase(context);
+        await saveTranslatedCollection.execute({
+            collectionId: "collection:1",
+            languageCode: "en",
+            items: [
+                { itemId: "element:1", value: "Translated Value 1 EN" },
+                { itemId: "element:2", value: "Translated Value 2 EN" }
+            ]
+        });
+
+        await saveTranslatedCollection.execute({
+            collectionId: "collection:1",
+            languageCode: "de",
+            items: [
+                { itemId: "element:1", value: "Translated Value 1 DE" },
+                { itemId: "element:2", value: "Translated Value 2 DE" }
+            ]
+        });
+
+        // Test
+        const deleteTranslatedCollection = new DeleteTranslatedCollectionUseCase(context);
+        await deleteTranslatedCollection.execute({
+            collectionId: "collection:1",
+            languageCode: "en"
+        });
+
+        const getTranslatedCollection = new GetTranslatedCollectionUseCase(context);
+
+        await expect(
+            getTranslatedCollection.execute({
+                collectionId: "collection:1",
+                languageCode: "en"
+            })
+        ).rejects.toThrow("not found");
+
+        const deCollection = await getTranslatedCollection.execute({
+            collectionId: "collection:1",
+            languageCode: "de"
+        });
+
+        expect(deCollection.getCollectionId()).toBe("collection:1");
+        expect(deCollection.getLanguageCode()).toBe("de");
+    });
+});
diff --git a/packages/api-page-builder/src/translations/index.ts b/packages/api-page-builder/src/translations/index.ts
index b37419f089b..cecc7873775 100644
--- a/packages/api-page-builder/src/translations/index.ts
+++ b/packages/api-page-builder/src/translations/index.ts
@@ -3,9 +3,11 @@ export * from "./translatableCollection/useCases/GetTranslatableCollectionUseCas
 export * from "./translatableCollection/useCases/SaveTranslatableCollectionUseCase";
 export * from "./translatableCollection/useCases/GetOrCreateTranslatableCollectionUseCase";
 export * from "./translatableCollection/useCases/CloneTranslatableCollectionUseCase";
+export * from "./translatableCollection/useCases/DeleteTranslatableCollectionUseCase";
 
 // TranslatedCollection
 export * from "./translatedCollection/useCases/GetTranslatedCollectionUseCase";
 export * from "./translatedCollection/useCases/CloneTranslatedCollectionUseCase";
 export * from "./translatedCollection/useCases/SaveTranslatedCollectionUseCase";
 export * from "./translatedCollection/useCases/GetOrCreateTranslatedCollectionUseCase";
+export * from "./translatedCollection/useCases/DeleteTranslatedCollectionUseCase";
diff --git a/packages/api-page-builder/src/translations/translatableCollection/graphql/resolvers.ts b/packages/api-page-builder/src/translations/translatableCollection/graphql/resolvers.ts
index 4d0268f1fec..6eb878e6acf 100644
--- a/packages/api-page-builder/src/translations/translatableCollection/graphql/resolvers.ts
+++ b/packages/api-page-builder/src/translations/translatableCollection/graphql/resolvers.ts
@@ -5,12 +5,17 @@ import { SaveTranslatableCollectionUseCase } from "~/translations/translatableCo
 import type { GqlTranslatableItemDTO } from "~/translations/translatableCollection/graphql/GqlTranslatableItemDTO";
 import { GetTranslatableCollectionByIdRepository } from "~/translations/translatableCollection/repository/GetTranslatableCollectionByIdRepository";
 import { GqlTranslatableCollectionMapper } from "~/translations/translatableCollection/graphql/GqlTranslatableCollectionMapper";
+import { DeleteTranslatableCollectionUseCase } from "~/translations";
 
 interface UpdateTranslatableCollectionParams {
     collectionId: string;
     items: GqlTranslatableItemDTO[];
 }
 
+interface DeleteTranslatableCollectionParams {
+    collectionId: string;
+}
+
 export const translatableCollectionResolvers: Resolvers<PbContext> = {
     TranslationsQuery: {
         getTranslatableCollection: async (_, args, context) => {
@@ -39,6 +44,18 @@ export const translatableCollectionResolvers: Resolvers<PbContext> = {
             } catch (err) {
                 return new ErrorResponse(err);
             }
+        },
+        deleteTranslatableCollection: async (_, args, context) => {
+            const { collectionId } = args as DeleteTranslatableCollectionParams;
+
+            try {
+                const useCase = new DeleteTranslatableCollectionUseCase(context);
+                await useCase.execute({ collectionId });
+
+                return new Response(true);
+            } catch (err) {
+                return new ErrorResponse(err);
+            }
         }
     }
 };
diff --git a/packages/api-page-builder/src/translations/translatableCollection/graphql/schema.ts b/packages/api-page-builder/src/translations/translatableCollection/graphql/schema.ts
index debbe433dd4..ff3c271c7a4 100644
--- a/packages/api-page-builder/src/translations/translatableCollection/graphql/schema.ts
+++ b/packages/api-page-builder/src/translations/translatableCollection/graphql/schema.ts
@@ -28,7 +28,7 @@ export const translatableCollectionSchema = /* GraphQL*/ `
         data: TranslatableCollection
         error: PbError
     }
-
+    
     extend type TranslationsQuery {
         """Get the source collection with all the items that need to be translated."""
         getTranslatableCollection(collectionId: ID!): TranslatableCollectionResponse
@@ -39,5 +39,7 @@ export const translatableCollectionSchema = /* GraphQL*/ `
             collectionId: ID!
             items: [TranslatableItemInput!]!
         ): SaveTranslatableCollectionResponse
+        
+        deleteTranslatableCollection(collectionId: ID!): BooleanResponse
     }
 `;
diff --git a/packages/api-page-builder/src/translations/translatableCollection/repository/DeleteTranslatableCollectionRepository.ts b/packages/api-page-builder/src/translations/translatableCollection/repository/DeleteTranslatableCollectionRepository.ts
new file mode 100644
index 00000000000..87ff8cfdaf3
--- /dev/null
+++ b/packages/api-page-builder/src/translations/translatableCollection/repository/DeleteTranslatableCollectionRepository.ts
@@ -0,0 +1,32 @@
+import { PbContext } from "~/types";
+import { GetModel } from "~/translations/GetModel";
+import { TranslatableCollectionDTO } from "./mappers/TranslatableCollectionDTO";
+
+export class DeleteTranslatableCollectionRepository {
+    private readonly context: PbContext;
+
+    constructor(context: PbContext) {
+        this.context = context;
+    }
+
+    async execute(collectionId: string): Promise<void> {
+        const model = await GetModel.byModelId(this.context, "translatableCollection");
+
+        // `cms.getEntry` throws an error if an entry is not found.
+        try {
+            const existingEntry = await this.context.cms.getEntry<TranslatableCollectionDTO>(
+                model,
+                {
+                    where: { collectionId, latest: true }
+                }
+            );
+
+            await this.context.cms.deleteEntry(model, existingEntry.entryId, { permanently: true });
+        } catch {
+            // If a record doesn't exist, then there's nothing to delete, and we can exit.
+            console.log(
+                `[DeleteTranslatableCollectionRepository]: Collection doesn't exist: ${collectionId}`
+            );
+        }
+    }
+}
diff --git a/packages/api-page-builder/src/translations/translatableCollection/repository/GetTranslatableCollectionByIdRepository.ts b/packages/api-page-builder/src/translations/translatableCollection/repository/GetTranslatableCollectionByIdRepository.ts
index b743baf3787..291ba8304de 100644
--- a/packages/api-page-builder/src/translations/translatableCollection/repository/GetTranslatableCollectionByIdRepository.ts
+++ b/packages/api-page-builder/src/translations/translatableCollection/repository/GetTranslatableCollectionByIdRepository.ts
@@ -15,17 +15,23 @@ export class GetTranslatableCollectionByIdRepository {
     async execute(collectionId: string): Promise<TranslatableCollection> {
         const model = await GetModel.byModelId(this.context, "translatableCollection");
 
-        const existingEntry = await this.context.cms.getEntry<TranslatableCollectionDTO>(model, {
-            where: { collectionId, latest: true }
-        });
+        try {
+            const existingEntry = await this.context.cms.getEntry<TranslatableCollectionDTO>(
+                model,
+                {
+                    where: { collectionId, latest: true }
+                }
+            );
 
-        if (!existingEntry) {
+            return TranslatableCollectionMapper.fromDTO(
+                existingEntry.values,
+                existingEntry.entryId
+            );
+        } catch {
             throw new WebinyError({
                 message: `TranslatableCollection "${collectionId}" not found!`,
                 code: "NOT_FOUND"
             });
         }
-
-        return TranslatableCollectionMapper.fromDTO(existingEntry.values, existingEntry.entryId);
     }
 }
diff --git a/packages/api-page-builder/src/translations/translatableCollection/useCases/DeleteTranslatableCollectionUseCase.ts b/packages/api-page-builder/src/translations/translatableCollection/useCases/DeleteTranslatableCollectionUseCase.ts
new file mode 100644
index 00000000000..b5c12fe05a0
--- /dev/null
+++ b/packages/api-page-builder/src/translations/translatableCollection/useCases/DeleteTranslatableCollectionUseCase.ts
@@ -0,0 +1,20 @@
+import { PbContext } from "~/graphql/types";
+import { DeleteTranslatableCollectionRepository } from "~/translations/translatableCollection/repository/DeleteTranslatableCollectionRepository";
+
+export interface DeleteTranslatableCollectionParams {
+    collectionId: string;
+}
+
+export class DeleteTranslatableCollectionUseCase {
+    private readonly context: PbContext;
+
+    constructor(context: PbContext) {
+        this.context = context;
+    }
+
+    async execute(params: DeleteTranslatableCollectionParams): Promise<void> {
+        const deleteRepository = new DeleteTranslatableCollectionRepository(this.context);
+
+        await deleteRepository.execute(params.collectionId);
+    }
+}
diff --git a/packages/api-page-builder/src/translations/translatedCollection/graphql/resolvers.ts b/packages/api-page-builder/src/translations/translatedCollection/graphql/resolvers.ts
index 0717d5c989a..bb56d13fe5e 100644
--- a/packages/api-page-builder/src/translations/translatedCollection/graphql/resolvers.ts
+++ b/packages/api-page-builder/src/translations/translatedCollection/graphql/resolvers.ts
@@ -4,13 +4,21 @@ import type { PbContext } from "~/graphql/types";
 import { GqlTranslatedCollectionMapper } from "~/translations/translatedCollection/graphql/mappers/GqlTranslatedCollectionMapper";
 import { SaveTranslatedCollectionUseCase } from "~/translations/translatedCollection/useCases/SaveTranslatedCollectionUseCase";
 import { GetOrCreateTranslatedCollectionUseCase } from "~/translations/translatedCollection/useCases/GetOrCreateTranslatedCollectionUseCase";
-import { GetTranslatableCollectionUseCase } from "~/translations";
+import {
+    DeleteTranslatedCollectionUseCase,
+    GetTranslatableCollectionUseCase
+} from "~/translations";
 
 interface GetTranslatedCollectionParams {
     collectionId: string;
     languageCode: string;
 }
 
+interface DeleteTranslatedCollectionParams {
+    collectionId: string;
+    languageCode?: string;
+}
+
 interface UpdateTranslatedCollectionParams {
     collectionId: string;
     languageCode: string;
@@ -76,6 +84,18 @@ export const translatedCollectionResolvers: Resolvers<PbContext> = {
             } catch (err) {
                 return new ErrorResponse(err);
             }
+        },
+        deleteTranslatedCollection: async (_, args, context) => {
+            const { collectionId, languageCode } = args as DeleteTranslatedCollectionParams;
+
+            try {
+                const useCase = new DeleteTranslatedCollectionUseCase(context);
+                await useCase.execute({ collectionId, languageCode });
+
+                return new Response(true);
+            } catch (err) {
+                return new ErrorResponse(err);
+            }
         }
     }
 };
diff --git a/packages/api-page-builder/src/translations/translatedCollection/graphql/schema.ts b/packages/api-page-builder/src/translations/translatedCollection/graphql/schema.ts
index ca4cdc5a48d..d52189ed203 100644
--- a/packages/api-page-builder/src/translations/translatedCollection/graphql/schema.ts
+++ b/packages/api-page-builder/src/translations/translatedCollection/graphql/schema.ts
@@ -41,5 +41,7 @@ export const translatedCollectionSchema = /* GraphQL*/ `
             languageCode: String!
             items: [TranslatedItemInput!]!
         ): SaveTranslatedCollectionResponse
+        
+        deleteTranslatedCollection(collectionId: ID!, languageCode: String): BooleanResponse
     }
 `;
diff --git a/packages/api-page-builder/src/translations/translatedCollection/repository/DeleteTranslatedCollectionRepository.ts b/packages/api-page-builder/src/translations/translatedCollection/repository/DeleteTranslatedCollectionRepository.ts
new file mode 100644
index 00000000000..18f15b16120
--- /dev/null
+++ b/packages/api-page-builder/src/translations/translatedCollection/repository/DeleteTranslatedCollectionRepository.ts
@@ -0,0 +1,40 @@
+import { PbContext } from "~/types";
+import { GetModel } from "~/translations/GetModel";
+import { TranslatedCollectionDTO } from "~/translations/translatedCollection/repository/mappers/TranslatedCollectionDTO";
+
+export interface DeleteTranslatedCollectionParams {
+    collectionId: string;
+    languageCode?: string;
+}
+
+export class DeleteTranslatedCollectionRepository {
+    private readonly context: PbContext;
+
+    constructor(context: PbContext) {
+        this.context = context;
+    }
+
+    async execute(params: DeleteTranslatedCollectionParams): Promise<void> {
+        const model = await GetModel.byModelId(this.context, "translatedCollection");
+
+        const filter: DeleteTranslatedCollectionParams = {
+            collectionId: params.collectionId
+        };
+
+        if (params.languageCode) {
+            filter.languageCode = params.languageCode;
+        }
+
+        const [entries] = await this.context.cms.listEntries<TranslatedCollectionDTO>(model, {
+            where: { latest: true, ...filter }
+        });
+
+        await Promise.all(
+            entries.map(entry => {
+                return this.context.cms.deleteEntry(model, entry.entryId, {
+                    permanently: true
+                });
+            })
+        );
+    }
+}
diff --git a/packages/api-page-builder/src/translations/translatedCollection/repository/GetTranslatedCollectionRepository.ts b/packages/api-page-builder/src/translations/translatedCollection/repository/GetTranslatedCollectionRepository.ts
index 692271ee71a..43fcca7acde 100644
--- a/packages/api-page-builder/src/translations/translatedCollection/repository/GetTranslatedCollectionRepository.ts
+++ b/packages/api-page-builder/src/translations/translatedCollection/repository/GetTranslatedCollectionRepository.ts
@@ -20,21 +20,21 @@ export class GetTranslatedCollectionRepository {
     async execute(params: GetTranslatedCollectionParams): Promise<TranslatedCollection> {
         const model = await GetModel.byModelId(this.context, "translatedCollection");
 
-        const existingEntry = await this.context.cms.getEntry<TranslatedCollectionDTO>(model, {
-            where: {
-                collectionId: params.collectionId,
-                languageCode: params.languageCode,
-                latest: true
-            }
-        });
+        try {
+            const existingEntry = await this.context.cms.getEntry<TranslatedCollectionDTO>(model, {
+                where: {
+                    collectionId: params.collectionId,
+                    languageCode: params.languageCode,
+                    latest: true
+                }
+            });
 
-        if (!existingEntry) {
+            return TranslatedCollectionMapper.fromDTO(existingEntry.values, existingEntry.entryId);
+        } catch {
             throw new WebinyError({
                 message: `TranslatedCollection "${params.collectionId}" for language "${params.languageCode}" was not found!`,
                 code: "NOT_FOUND"
             });
         }
-
-        return TranslatedCollectionMapper.fromDTO(existingEntry.values, existingEntry.entryId);
     }
 }
diff --git a/packages/api-page-builder/src/translations/translatedCollection/useCases/DeleteTranslatedCollectionUseCase.ts b/packages/api-page-builder/src/translations/translatedCollection/useCases/DeleteTranslatedCollectionUseCase.ts
new file mode 100644
index 00000000000..3257a89d16a
--- /dev/null
+++ b/packages/api-page-builder/src/translations/translatedCollection/useCases/DeleteTranslatedCollectionUseCase.ts
@@ -0,0 +1,21 @@
+import { PbContext } from "~/graphql/types";
+import { DeleteTranslatedCollectionRepository } from "~/translations/translatedCollection/repository/DeleteTranslatedCollectionRepository";
+
+export interface DeleteTranslatedCollectionParams {
+    collectionId: string;
+    languageCode?: string;
+}
+
+export class DeleteTranslatedCollectionUseCase {
+    private readonly context: PbContext;
+
+    constructor(context: PbContext) {
+        this.context = context;
+    }
+
+    async execute(params: DeleteTranslatedCollectionParams): Promise<void> {
+        const deleteRepository = new DeleteTranslatedCollectionRepository(this.context);
+
+        await deleteRepository.execute(params);
+    }
+}

From ff32ec70c970d7a06a90b82fc855e78d6cf8cf4d Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Tue, 8 Apr 2025 13:48:50 +0200
Subject: [PATCH 43/52] fix(serverless-cms-aws): add control for full website
 render

---
 .../src/createWebsiteApp.ts                   |   6 +-
 .../src/enterprise/createWebsiteApp.ts        |   6 +-
 .../src/website/plugins/renderWebsite.ts      | 109 ++++++++++--------
 3 files changed, 72 insertions(+), 49 deletions(-)

diff --git a/packages/serverless-cms-aws/src/createWebsiteApp.ts b/packages/serverless-cms-aws/src/createWebsiteApp.ts
index 0d715106f75..d699d98cc35 100644
--- a/packages/serverless-cms-aws/src/createWebsiteApp.ts
+++ b/packages/serverless-cms-aws/src/createWebsiteApp.ts
@@ -5,11 +5,13 @@ import {
     lambdaEdgeWarning,
     renderWebsite,
     telemetryNoLongerNewUser,
-    ensureApiDeployedBeforeBuild
+    ensureApiDeployedBeforeBuild,
+    IRenderWebsiteParams
 } from "./website/plugins";
 import { uploadAppToS3 } from "./react/plugins";
 
 export interface CreateWebsiteAppParams extends CreateWebsitePulumiAppParams {
+    renderWebsiteAfterDeploy?: (params: IRenderWebsiteParams) => boolean;
     plugins?: PluginCollection;
 }
 
@@ -18,7 +20,7 @@ export function createWebsiteApp(projectAppParams: CreateWebsiteAppParams = {})
         uploadAppToS3({ folder: "apps/website" }),
         generateCommonHandlers,
         lambdaEdgeWarning,
-        renderWebsite,
+        renderWebsite({ prerender: projectAppParams.renderWebsiteAfterDeploy ?? (() => true) }),
         telemetryNoLongerNewUser,
         ensureApiDeployedBeforeBuild
     ];
diff --git a/packages/serverless-cms-aws/src/enterprise/createWebsiteApp.ts b/packages/serverless-cms-aws/src/enterprise/createWebsiteApp.ts
index 792be9a07ce..d8aa9adc225 100644
--- a/packages/serverless-cms-aws/src/enterprise/createWebsiteApp.ts
+++ b/packages/serverless-cms-aws/src/enterprise/createWebsiteApp.ts
@@ -8,11 +8,13 @@ import {
     lambdaEdgeWarning,
     renderWebsite,
     telemetryNoLongerNewUser,
-    ensureApiDeployedBeforeBuild
+    ensureApiDeployedBeforeBuild,
+    IRenderWebsiteParams
 } from "~/website/plugins";
 import { uploadAppToS3 } from "~/react/plugins";
 
 export interface CreateWebsiteAppParams extends CreateWebsitePulumiAppParams {
+    renderWebsiteAfterDeploy?: (params: IRenderWebsiteParams) => boolean;
     plugins?: PluginCollection;
 }
 
@@ -21,7 +23,7 @@ export function createWebsiteApp(projectAppParams: CreateWebsiteAppParams = {})
         uploadAppToS3({ folder: "apps/website" }),
         generateCommonHandlers,
         lambdaEdgeWarning,
-        renderWebsite,
+        renderWebsite({ prerender: projectAppParams.renderWebsiteAfterDeploy ?? (() => true) }),
         telemetryNoLongerNewUser,
         ensureApiDeployedBeforeBuild
     ];
diff --git a/packages/serverless-cms-aws/src/website/plugins/renderWebsite.ts b/packages/serverless-cms-aws/src/website/plugins/renderWebsite.ts
index 1961e1707b5..108f5b0f04c 100644
--- a/packages/serverless-cms-aws/src/website/plugins/renderWebsite.ts
+++ b/packages/serverless-cms-aws/src/website/plugins/renderWebsite.ts
@@ -2,58 +2,77 @@ import { EventBridgeClient, PutEventsCommand } from "@webiny/aws-sdk/client-even
 import { CliContext } from "@webiny/cli/types";
 import { getStackOutput } from "@webiny/cli-plugin-deploy-pulumi/utils";
 
+export interface IRenderWebsiteParams {
+    env: string;
+    inputs: IRenderWebsiteParamsInputs;
+}
+
+export interface IRenderWebsiteParamsInputs {
+    preview?: boolean;
+    build?: boolean;
+}
+
+interface RenderWebsiteParams {
+    prerender: (params: IRenderWebsiteParams) => boolean;
+}
+
 /**
  * On every deployment of the Website project application, this plugin ensures all pages created
  * with the Webiny Page Builder application are re-rendered.
  */
-export const renderWebsite = {
-    type: "hook-after-deploy",
-    name: "hook-after-deploy-website-render",
-    async hook(params: Record<string, any>, context: CliContext) {
-        if (params.inputs.build === false) {
-            context.info(`"--no-build" argument detected - skipping Website re-rendering.`);
-            return;
-        }
+export const renderWebsite = (renderWebsiteParams: RenderWebsiteParams) => {
+    return {
+        type: "hook-after-deploy",
+        name: "hook-after-deploy-website-render",
+        async hook(params: IRenderWebsiteParams, context: CliContext) {
+            if (params.inputs.build === false) {
+                context.info(`"--no-build" argument detected - skipping Website re-rendering.`);
+                return;
+            }
 
-        // No need to re-render the website if we're doing a preview.
-        if (params.inputs.preview) {
-            return;
-        }
+            // No need to re-render the website if we're doing a preview.
+            if (params.inputs.preview) {
+                return;
+            }
 
-        const coreOutput = getStackOutput({ folder: "apps/core", env: params.env });
-
-        context.info("Issuing a complete website render job...");
-
-        try {
-            const client = new EventBridgeClient({ region: coreOutput["region"] });
-
-            const result = await client.send(
-                new PutEventsCommand({
-                    Entries: [
-                        {
-                            Source: "webiny-cli",
-                            EventBusName: coreOutput["eventBusArn"],
-                            DetailType: "RenderPages",
-                            Detail: JSON.stringify({ path: "*", tenant: "*" })
-                        }
-                    ]
-                })
-            );
-
-            const entry = result.Entries?.[0];
-            if (entry?.ErrorMessage) {
-                throw new Error(entry.ErrorMessage);
+            if (!renderWebsiteParams.prerender(params)) {
+                context.info("Skipping complete website rendering.");
+                return;
             }
 
-            context.success("Website re-render job successfully issued.");
-            context.info(
-                "Please note that it can take a couple of minutes for the website to be fully updated."
-            );
-        } catch (e) {
-            context.error(
-                `An error occurred while trying to update default Page Builder app's settings!`
-            );
-            console.log(e);
+            const coreOutput = getStackOutput({ folder: "apps/core", env: params.env });
+
+            context.info("Issuing a complete website rendering job...");
+
+            try {
+                const client = new EventBridgeClient({ region: coreOutput["region"] });
+
+                const result = await client.send(
+                    new PutEventsCommand({
+                        Entries: [
+                            {
+                                Source: "webiny-cli",
+                                EventBusName: coreOutput["eventBusArn"],
+                                DetailType: "RenderPages",
+                                Detail: JSON.stringify({ path: "*", tenant: "*" })
+                            }
+                        ]
+                    })
+                );
+
+                const entry = result.Entries?.[0];
+                if (entry?.ErrorMessage) {
+                    throw new Error(entry.ErrorMessage);
+                }
+
+                context.success("Website rendering job successfully issued.");
+                context.info(
+                    "Please note that it can take a couple of minutes for the website to be fully updated."
+                );
+            } catch (e) {
+                context.error(`An error occurred while issuing a website rendering job!`);
+                console.log(e);
+            }
         }
-    }
+    };
 };

From ef80a269b14813520c9cb8485461184582fa169c Mon Sep 17 00:00:00 2001
From: Adrian Smijulj <adrian1358@gmail.com>
Date: Fri, 11 Apr 2025 06:16:45 +0200
Subject: [PATCH 44/52] fix: add `PbEditorPageElementGroupPlugin` React plugin
 (#4598)

---
 .../PbEditorPageElementGroupPlugin.tsx        | 26 +++++++++++++++++++
 .../app-page-builder/src/plugins/index.ts     |  1 +
 2 files changed, 27 insertions(+)
 create mode 100644 packages/app-page-builder/src/plugins/PbEditorPageElementGroupPlugin.tsx

diff --git a/packages/app-page-builder/src/plugins/PbEditorPageElementGroupPlugin.tsx b/packages/app-page-builder/src/plugins/PbEditorPageElementGroupPlugin.tsx
new file mode 100644
index 00000000000..f00e9f71c1e
--- /dev/null
+++ b/packages/app-page-builder/src/plugins/PbEditorPageElementGroupPlugin.tsx
@@ -0,0 +1,26 @@
+import React from "react";
+import { legacyPluginToReactComponent } from "@webiny/app/utils";
+
+interface PbEditorPageElementGroupPlugin {
+    name: string;
+    title: string;
+    icon: React.ReactNode;
+    emptyView?: React.ReactNode;
+}
+
+export const PbEditorPageElementGroupPlugin =
+    legacyPluginToReactComponent<PbEditorPageElementGroupPlugin>({
+        pluginType: "pb-editor-page-element-group",
+        componentDisplayName: "PbEditorPageElementGroupPlugin",
+        mapProps: props => {
+            const { title, icon, emptyView } = props;
+            return {
+                ...props,
+                group: {
+                    title,
+                    icon,
+                    emptyView
+                }
+            };
+        }
+    });
diff --git a/packages/app-page-builder/src/plugins/index.ts b/packages/app-page-builder/src/plugins/index.ts
index bef3be129d8..a55f7e15f46 100644
--- a/packages/app-page-builder/src/plugins/index.ts
+++ b/packages/app-page-builder/src/plugins/index.ts
@@ -1,4 +1,5 @@
 export * from "./PbPageLayoutPlugin";
 export * from "./PbEditorPageElementAdvancedSettingsPlugin";
 export * from "./PbRenderElementPlugin";
+export * from "./PbEditorPageElementGroupPlugin";
 export * from "./PbEditorPageElementPlugin";

From 4669af7399c67e5dd9039ad83ac1c0cbeef7cc38 Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Fri, 11 Apr 2025 11:18:49 +0200
Subject: [PATCH 45/52] fix(lexical-theme): add explicit theme.css imports
 (#4596)

---
 packages/api-file-manager-aco/tsconfig.build.json | 2 +-
 packages/api-file-manager-aco/tsconfig.json       | 6 +++---
 packages/app-serverless-cms/package.json          | 8 ++++++++
 packages/app-serverless-cms/src/styles.scss       | 3 +++
 packages/app-serverless-cms/tsconfig.build.json   | 1 +
 packages/app-serverless-cms/tsconfig.json         | 3 +++
 packages/app-website/package.json                 | 2 ++
 packages/app-website/src/styles.scss              | 1 +
 packages/app-website/tsconfig.build.json          | 1 +
 packages/app-website/tsconfig.json                | 3 +++
 packages/lexical-theme/src/createTheme.ts         | 2 --
 yarn.lock                                         | 2 ++
 12 files changed, 28 insertions(+), 6 deletions(-)

diff --git a/packages/api-file-manager-aco/tsconfig.build.json b/packages/api-file-manager-aco/tsconfig.build.json
index 000aa8550e1..513919c0630 100644
--- a/packages/api-file-manager-aco/tsconfig.build.json
+++ b/packages/api-file-manager-aco/tsconfig.build.json
@@ -2,8 +2,8 @@
   "extends": "../../tsconfig.build.json",
   "include": ["src"],
   "references": [
-    { "path": "../api/tsconfig.build.json" },
     { "path": "../api-aco/tsconfig.build.json" },
+    { "path": "../api/tsconfig.build.json" },
     { "path": "../api-admin-users/tsconfig.build.json" },
     { "path": "../api-headless-cms/tsconfig.build.json" },
     { "path": "../api-i18n/tsconfig.build.json" },
diff --git a/packages/api-file-manager-aco/tsconfig.json b/packages/api-file-manager-aco/tsconfig.json
index 54c861d5290..cbea3636d1d 100644
--- a/packages/api-file-manager-aco/tsconfig.json
+++ b/packages/api-file-manager-aco/tsconfig.json
@@ -2,8 +2,8 @@
   "extends": "../../tsconfig.json",
   "include": ["src", "__tests__"],
   "references": [
-    { "path": "../api" },
     { "path": "../api-aco" },
+    { "path": "../api" },
     { "path": "../api-admin-users" },
     { "path": "../api-headless-cms" },
     { "path": "../api-i18n" },
@@ -23,10 +23,10 @@
     "paths": {
       "~/*": ["./src/*"],
       "~tests/*": ["./__tests__/*"],
-      "@webiny/api/*": ["../api/src/*"],
-      "@webiny/api": ["../api/src"],
       "@webiny/api-aco/*": ["../api-aco/src/*"],
       "@webiny/api-aco": ["../api-aco/src"],
+      "@webiny/api/*": ["../api/src/*"],
+      "@webiny/api": ["../api/src"],
       "@webiny/api-admin-users/*": ["../api-admin-users/src/*"],
       "@webiny/api-admin-users": ["../api-admin-users/src"],
       "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"],
diff --git a/packages/app-serverless-cms/package.json b/packages/app-serverless-cms/package.json
index 82af93bcb86..4d6673c6fe8 100644
--- a/packages/app-serverless-cms/package.json
+++ b/packages/app-serverless-cms/package.json
@@ -35,6 +35,7 @@
     "@webiny/app-websockets": "0.0.0",
     "@webiny/lexical-editor-actions": "0.0.0",
     "@webiny/lexical-editor-pb-element": "0.0.0",
+    "@webiny/lexical-theme": "0.0.0",
     "@webiny/plugins": "0.0.0",
     "apollo-cache": "^1.3.5",
     "apollo-client": "^2.6.10",
@@ -64,5 +65,12 @@
     "plugins": {
       "removeViewBox": false
     }
+  },
+  "adio": {
+    "ignore": {
+      "dependencies": [
+        "@webiny/lexical-theme"
+      ]
+    }
   }
 }
diff --git a/packages/app-serverless-cms/src/styles.scss b/packages/app-serverless-cms/src/styles.scss
index d567de7b819..7e5427e0aa0 100644
--- a/packages/app-serverless-cms/src/styles.scss
+++ b/packages/app-serverless-cms/src/styles.scss
@@ -7,3 +7,6 @@
 @import "~@webiny/app-admin/components/RichTextEditor/tools/paragraph/styles.scss";
 @import "~@webiny/app-admin/components/RichTextEditor/tools/textColor/styles.scss";
 @import "~@webiny/app-admin/components/RichTextEditor/tools/image/styles.scss";
+
+// Import Lexical theme styles
+@import "~@webiny/lexical-theme/theme.css";
diff --git a/packages/app-serverless-cms/tsconfig.build.json b/packages/app-serverless-cms/tsconfig.build.json
index 421e090f48e..586718b1737 100644
--- a/packages/app-serverless-cms/tsconfig.build.json
+++ b/packages/app-serverless-cms/tsconfig.build.json
@@ -27,6 +27,7 @@
     { "path": "../app-websockets/tsconfig.build.json" },
     { "path": "../lexical-editor-actions/tsconfig.build.json" },
     { "path": "../lexical-editor-pb-element/tsconfig.build.json" },
+    { "path": "../lexical-theme/tsconfig.build.json" },
     { "path": "../plugins/tsconfig.build.json" }
   ],
   "compilerOptions": {
diff --git a/packages/app-serverless-cms/tsconfig.json b/packages/app-serverless-cms/tsconfig.json
index 3838a1c5e85..4602cd7c4a0 100644
--- a/packages/app-serverless-cms/tsconfig.json
+++ b/packages/app-serverless-cms/tsconfig.json
@@ -27,6 +27,7 @@
     { "path": "../app-websockets" },
     { "path": "../lexical-editor-actions" },
     { "path": "../lexical-editor-pb-element" },
+    { "path": "../lexical-theme" },
     { "path": "../plugins" }
   ],
   "compilerOptions": {
@@ -86,6 +87,8 @@
       "@webiny/lexical-editor-actions": ["../lexical-editor-actions/src"],
       "@webiny/lexical-editor-pb-element/*": ["../lexical-editor-pb-element/src/*"],
       "@webiny/lexical-editor-pb-element": ["../lexical-editor-pb-element/src"],
+      "@webiny/lexical-theme/*": ["../lexical-theme/src/*"],
+      "@webiny/lexical-theme": ["../lexical-theme/src"],
       "@webiny/plugins/*": ["../plugins/src/*"],
       "@webiny/plugins": ["../plugins/src"]
     },
diff --git a/packages/app-website/package.json b/packages/app-website/package.json
index 8069015539e..eab92000de3 100644
--- a/packages/app-website/package.json
+++ b/packages/app-website/package.json
@@ -19,6 +19,7 @@
     "@webiny/app-page-builder": "0.0.0",
     "@webiny/app-page-builder-elements": "0.0.0",
     "@webiny/app-theme": "0.0.0",
+    "@webiny/lexical-theme": "0.0.0",
     "@webiny/plugins": "0.0.0",
     "@webiny/react-router": "0.0.0",
     "apollo-client": "^2.6.10",
@@ -48,6 +49,7 @@
   "adio": {
     "ignore": {
       "dependencies": [
+        "@webiny/lexical-theme",
         "reset-css"
       ]
     }
diff --git a/packages/app-website/src/styles.scss b/packages/app-website/src/styles.scss
index 974f4c0e466..3ee8c038df9 100644
--- a/packages/app-website/src/styles.scss
+++ b/packages/app-website/src/styles.scss
@@ -1 +1,2 @@
 @import "./styles/reset";
+@import "~@webiny/lexical-theme/theme.css";
diff --git a/packages/app-website/tsconfig.build.json b/packages/app-website/tsconfig.build.json
index 64363676bf8..05fbee7c027 100644
--- a/packages/app-website/tsconfig.build.json
+++ b/packages/app-website/tsconfig.build.json
@@ -6,6 +6,7 @@
     { "path": "../app-page-builder/tsconfig.build.json" },
     { "path": "../app-page-builder-elements/tsconfig.build.json" },
     { "path": "../app-theme/tsconfig.build.json" },
+    { "path": "../lexical-theme/tsconfig.build.json" },
     { "path": "../plugins/tsconfig.build.json" },
     { "path": "../react-router/tsconfig.build.json" }
   ],
diff --git a/packages/app-website/tsconfig.json b/packages/app-website/tsconfig.json
index 345217cbf85..d9c74336f3f 100644
--- a/packages/app-website/tsconfig.json
+++ b/packages/app-website/tsconfig.json
@@ -6,6 +6,7 @@
     { "path": "../app-page-builder" },
     { "path": "../app-page-builder-elements" },
     { "path": "../app-theme" },
+    { "path": "../lexical-theme" },
     { "path": "../plugins" },
     { "path": "../react-router" }
   ],
@@ -24,6 +25,8 @@
       "@webiny/app-page-builder-elements": ["../app-page-builder-elements/src"],
       "@webiny/app-theme/*": ["../app-theme/src/*"],
       "@webiny/app-theme": ["../app-theme/src"],
+      "@webiny/lexical-theme/*": ["../lexical-theme/src/*"],
+      "@webiny/lexical-theme": ["../lexical-theme/src"],
       "@webiny/plugins/*": ["../plugins/src/*"],
       "@webiny/plugins": ["../plugins/src"],
       "@webiny/react-router/*": ["../react-router/src/*"],
diff --git a/packages/lexical-theme/src/createTheme.ts b/packages/lexical-theme/src/createTheme.ts
index 450a03f1364..c81ff17c79e 100644
--- a/packages/lexical-theme/src/createTheme.ts
+++ b/packages/lexical-theme/src/createTheme.ts
@@ -1,6 +1,4 @@
 import type { EditorThemeClasses } from "lexical";
-
-import "./theme.css";
 import { ThemeEmotionMap } from "~/types";
 
 export type EditorTheme = {
diff --git a/yarn.lock b/yarn.lock
index 213d069bb90..88b6d414ff2 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -14738,6 +14738,7 @@ __metadata:
     "@webiny/app-websockets": "npm:0.0.0"
     "@webiny/lexical-editor-actions": "npm:0.0.0"
     "@webiny/lexical-editor-pb-element": "npm:0.0.0"
+    "@webiny/lexical-theme": "npm:0.0.0"
     "@webiny/plugins": "npm:0.0.0"
     "@webiny/project-utils": "npm:0.0.0"
     apollo-cache: "npm:^1.3.5"
@@ -14940,6 +14941,7 @@ __metadata:
     "@webiny/app-page-builder": "npm:0.0.0"
     "@webiny/app-page-builder-elements": "npm:0.0.0"
     "@webiny/app-theme": "npm:0.0.0"
+    "@webiny/lexical-theme": "npm:0.0.0"
     "@webiny/plugins": "npm:0.0.0"
     "@webiny/project-utils": "npm:0.0.0"
     "@webiny/react-router": "npm:0.0.0"

From ed8f5057569ace71c0cae1b9f854f096578800fb Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Thu, 10 Apr 2025 16:34:19 +0200
Subject: [PATCH 46/52] fix(ui): forward "autocomplete" prop

---
 packages/ui/src/Input/Input.tsx | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/packages/ui/src/Input/Input.tsx b/packages/ui/src/Input/Input.tsx
index 4ad8615df6d..28f80b5cdb3 100644
--- a/packages/ui/src/Input/Input.tsx
+++ b/packages/ui/src/Input/Input.tsx
@@ -75,7 +75,8 @@ const rmwcProps = [
     "inputRef",
     "className",
     "maxLength",
-    "characterCount"
+    "characterCount",
+    "autoComplete"
 ];
 
 export const Input = (props: InputProps) => {

From 9684aa437130a123dc89e2b284ca280be122c77e Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Thu, 10 Apr 2025 16:36:29 +0200
Subject: [PATCH 47/52] fix(app-admin-cognito): add autocomplete=off to
 password inputs

---
 packages/app-admin-cognito/src/views/RequireNewPassword.tsx | 1 +
 packages/app-admin-cognito/src/views/SetNewPassword.tsx     | 1 +
 packages/app-admin-cognito/src/views/SignIn.tsx             | 2 +-
 packages/app-admin/src/ui/elements/form/PasswordElement.tsx | 1 +
 4 files changed, 4 insertions(+), 1 deletion(-)

diff --git a/packages/app-admin-cognito/src/views/RequireNewPassword.tsx b/packages/app-admin-cognito/src/views/RequireNewPassword.tsx
index 0d1034921dc..b8d9ce0c6da 100644
--- a/packages/app-admin-cognito/src/views/RequireNewPassword.tsx
+++ b/packages/app-admin-cognito/src/views/RequireNewPassword.tsx
@@ -39,6 +39,7 @@ export const RequireNewPassword = () => {
                                         type={"password"}
                                         label={"New password"}
                                         outlined={true}
+                                        autoComplete={"off"}
                                     />
                                 </Bind>
                             </Cell>
diff --git a/packages/app-admin-cognito/src/views/SetNewPassword.tsx b/packages/app-admin-cognito/src/views/SetNewPassword.tsx
index e901f846e7a..4f7fa4b00ce 100644
--- a/packages/app-admin-cognito/src/views/SetNewPassword.tsx
+++ b/packages/app-admin-cognito/src/views/SetNewPassword.tsx
@@ -78,6 +78,7 @@ export const SetNewPassword = () => {
                                         ]}
                                     >
                                         <Input
+                                            autoComplete={"off"}
                                             type={"password"}
                                             label={"Retype password"}
                                             outlined={true}
diff --git a/packages/app-admin-cognito/src/views/SignIn.tsx b/packages/app-admin-cognito/src/views/SignIn.tsx
index 7de9022360a..456aeb50043 100644
--- a/packages/app-admin-cognito/src/views/SignIn.tsx
+++ b/packages/app-admin-cognito/src/views/SignIn.tsx
@@ -84,7 +84,7 @@ const DefaultContent = (props: SignInDefaultContentProps) => {
                     </Cell>
                     <Cell span={12}>
                         <Bind name="password" validators={validation.create("required")}>
-                            <Input type={"password"} label={"Your password"} />
+                            <Input type={"password"} label={"Your password"} autoComplete={"off"} />
                         </Bind>
                     </Cell>
                     <Cell span={12} className={alignRight}>
diff --git a/packages/app-admin/src/ui/elements/form/PasswordElement.tsx b/packages/app-admin/src/ui/elements/form/PasswordElement.tsx
index d569eccab1d..30e736b0bb5 100644
--- a/packages/app-admin/src/ui/elements/form/PasswordElement.tsx
+++ b/packages/app-admin/src/ui/elements/form/PasswordElement.tsx
@@ -21,6 +21,7 @@ export class PasswordElement extends InputElement {
             >
                 <Input
                     type={"password"}
+                    autoComplete={"off"}
                     label={this.getLabel(props)}
                     placeholder={this.getPlaceholder(props)}
                     disabled={this.getIsDisabled(props)}

From 5259c929d2c227dcc19932f0747d08da8e475ac7 Mon Sep 17 00:00:00 2001
From: Swapnil M Mane <swapnilmmane@gmail.com>
Date: Wed, 16 Apr 2025 18:22:00 +0530
Subject: [PATCH 48/52] fix: handle notification error during deployment on
 Apple M4

The deployment was successful, but the CLI displayed an error caused by
the notifier. Since notification errors are non-critical and do not affect
the deployment outcome, they are now caught and safely ignored.
---
 packages/cli-plugin-deploy-pulumi/utils/notify.js | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/packages/cli-plugin-deploy-pulumi/utils/notify.js b/packages/cli-plugin-deploy-pulumi/utils/notify.js
index 23e7fda2628..bed793dd5c0 100644
--- a/packages/cli-plugin-deploy-pulumi/utils/notify.js
+++ b/packages/cli-plugin-deploy-pulumi/utils/notify.js
@@ -12,5 +12,8 @@ module.exports = ({ message }) => {
         });
 
         setTimeout(resolve, 100);
+    }).catch(() => {
+        // Suppress any unexpected promise rejections to ensure smooth user experience.
+        // Notification errors are non-critical and can be safely ignored.
     });
 };

From 467f5e4e86806460ce4457e8dcfa161819c4b559 Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Thu, 17 Apr 2025 15:39:13 +0200
Subject: [PATCH 49/52] fix(ui): add support for accordion action tooltip

---
 .../ui/src/Accordion/AccordionItemActions.tsx | 21 +++++++++++++++++--
 1 file changed, 19 insertions(+), 2 deletions(-)

diff --git a/packages/ui/src/Accordion/AccordionItemActions.tsx b/packages/ui/src/Accordion/AccordionItemActions.tsx
index d62af463c22..14f11b364b5 100644
--- a/packages/ui/src/Accordion/AccordionItemActions.tsx
+++ b/packages/ui/src/Accordion/AccordionItemActions.tsx
@@ -1,5 +1,6 @@
 import React from "react";
 import { IconButton } from "~/Button";
+import { Tooltip } from "~/Tooltip";
 
 interface AccordionItemActionsProps {
     children: React.ReactNode;
@@ -12,11 +13,17 @@ export const AccordionItemActions = ({ children }: AccordionItemActionsProps) =>
 export interface AccordionItemActionProps {
     icon: JSX.Element;
     onClick: () => void;
+    tooltip?: string;
     disabled?: boolean;
 }
 
-export const AccordionItemAction = ({ icon, onClick, disabled }: AccordionItemActionProps) => {
-    return (
+export const AccordionItemAction = ({
+    icon,
+    onClick,
+    tooltip,
+    disabled
+}: AccordionItemActionProps) => {
+    const iconButton = (
         <IconButton
             disabled={disabled}
             icon={icon}
@@ -26,6 +33,16 @@ export const AccordionItemAction = ({ icon, onClick, disabled }: AccordionItemAc
             }}
         />
     );
+
+    if (!tooltip) {
+        return iconButton;
+    }
+
+    return (
+        <Tooltip placement={"bottom"} content={tooltip}>
+            {iconButton}
+        </Tooltip>
+    );
 };
 
 export interface AccordionItemElementProps {

From d10517be7bf6313cc17059ed68b1154d02a75580 Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Thu, 17 Apr 2025 15:39:47 +0200
Subject: [PATCH 50/52] fix(app-headless-cms): add support for custom DZ
 actions

---
 .../src/ContentEntryEditorConfig.ts           |  9 ++-
 .../dynamicZone/MultiValueDynamicZone.tsx     | 11 +++-
 .../dynamicZone/SingleValueDynamicZone.tsx    | 65 +++++++++++++++----
 .../dynamicZone/dynamicZoneRenderer.tsx       |  2 +
 4 files changed, 68 insertions(+), 19 deletions(-)

diff --git a/packages/app-headless-cms/src/ContentEntryEditorConfig.ts b/packages/app-headless-cms/src/ContentEntryEditorConfig.ts
index b69d9838ec2..166a604e7f8 100644
--- a/packages/app-headless-cms/src/ContentEntryEditorConfig.ts
+++ b/packages/app-headless-cms/src/ContentEntryEditorConfig.ts
@@ -14,6 +14,7 @@ import {
     ContentEntryFormMeta,
     ContentEntryFormTitle
 } from "~/admin/views/contentEntries/ContentEntry/FullScreenContentEntry/FullScreenContentEntryHeaderLeft";
+import { SingleValueItemContainer } from "~/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone";
 
 export const ContentEntryEditorConfig = Object.assign(BaseContentEntryEditorConfig, {
     ContentEntry: Object.assign(ContentEntry, {
@@ -42,11 +43,9 @@ export const ContentEntryEditorConfig = Object.assign(BaseContentEntryEditorConf
                 useTemplate: DzField.useTemplate
             },
             Container: DzField.DynamicZoneContainer,
-            // SingleValue: {
-            //     Container: null,
-            //     ItemContainer: null,
-            //     Item: null
-            // },
+            SingleValue: {
+                ItemContainer: SingleValueItemContainer
+            },
             MultiValue: {
                 Container: DzField.MultiValueContainer,
                 ItemContainer: DzField.MultiValueItemContainer,
diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx
index d897dde29bc..a566aa2fe47 100644
--- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx
+++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx
@@ -42,6 +42,7 @@ export interface MultiValueItemContainerProps {
     title: React.ReactNode;
     description: string;
     icon: JSX.Element;
+    actions?: JSX.Element;
     template: CmsDynamicZoneTemplate;
     children: React.ReactNode;
 }
@@ -53,20 +54,28 @@ export const MultiValueItemContainer = makeDecoratable(
             <AccordionItem.Actions>
                 <AccordionItem.Action
                     icon={<ArrowUpIcon />}
+                    tooltip={"Move up"}
                     onClick={props.onMoveUp}
                     disabled={props.isFirst}
                 />
                 <AccordionItem.Action
                     icon={<ArrowDownIcon />}
+                    tooltip={"Move down"}
                     onClick={props.onMoveDown}
                     disabled={props.isLast}
                 />
                 <AccordionItem.Divider />
+                {props.actions ? <>{props.actions}</> : null}
                 <AccordionItem.Action
+                    tooltip={"Duplicate"}
                     icon={<CloneIcon />}
                     onClick={() => props.onClone(props.value)}
                 />
-                <AccordionItem.Action icon={<DeleteIcon />} onClick={props.onDelete} />
+                <AccordionItem.Action
+                    icon={<DeleteIcon />}
+                    onClick={props.onDelete}
+                    tooltip={"Delete"}
+                />
             </AccordionItem.Actions>
         );
 
diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx
index aabf0891b7f..9e5167eb44a 100644
--- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx
+++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx
@@ -18,7 +18,7 @@ import {
     ParentValueIndexProvider,
     ModelFieldProvider
 } from "~/admin/components/ModelFieldProvider";
-import { useConfirmationDialog } from "@webiny/app-admin";
+import { makeDecoratable, useConfirmationDialog } from "@webiny/app-admin";
 
 type GetBind = CmsModelFieldRendererProps["getBind"];
 
@@ -29,6 +29,51 @@ interface SingleValueDynamicZoneProps {
     getBind: GetBind;
 }
 
+interface TemplateValue {
+    _templateId: string;
+    [key: string]: any;
+}
+
+export interface SingleValueItemContainerProps {
+    value: TemplateValue;
+    contentModel: CmsModel;
+    onDelete: () => void;
+    title: React.ReactNode;
+    description: string;
+    icon: JSX.Element;
+    actions?: JSX.Element;
+    template: CmsDynamicZoneTemplate;
+    children: React.ReactNode;
+}
+
+export const SingleValueItemContainer = makeDecoratable(
+    "SingleValueItemContainer",
+    (props: SingleValueItemContainerProps) => {
+        const { template, actions, children } = props;
+        return (
+            <AccordionItem
+                title={template.name}
+                description={template.description}
+                icon={<TemplateIcon icon={template.icon} />}
+                open={true}
+                interactive={false}
+                actions={
+                    <AccordionItem.Actions>
+                        {actions ?? null}
+                        <AccordionItem.Action
+                            icon={<DeleteIcon />}
+                            onClick={props.onDelete}
+                            tooltip={"Delete"}
+                        />
+                    </AccordionItem.Actions>
+                }
+            >
+                {children}
+            </AccordionItem>
+        );
+    }
+);
+
 export const SingleValueDynamicZone = ({
     field,
     bind,
@@ -66,20 +111,14 @@ export const SingleValueDynamicZone = ({
                     <ParentValueIndexProvider index={-1}>
                         <ModelFieldProvider field={field}>
                             <Accordion>
-                                <AccordionItem
+                                <SingleValueItemContainer
+                                    template={template}
+                                    value={bind.value}
+                                    contentModel={contentModel}
+                                    onDelete={unsetValue}
                                     title={template.name}
                                     description={template.description}
                                     icon={<TemplateIcon icon={template.icon} />}
-                                    open={true}
-                                    interactive={false}
-                                    actions={
-                                        <AccordionItem.Actions>
-                                            <AccordionItem.Action
-                                                icon={<DeleteIcon />}
-                                                onClick={unsetValue}
-                                            />
-                                        </AccordionItem.Actions>
-                                    }
                                 >
                                     <TemplateProvider template={template}>
                                         <Fields
@@ -89,7 +128,7 @@ export const SingleValueDynamicZone = ({
                                             Bind={Bind}
                                         />
                                     </TemplateProvider>
-                                </AccordionItem>
+                                </SingleValueItemContainer>
                             </Accordion>
                         </ModelFieldProvider>
                     </ParentValueIndexProvider>
diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/dynamicZoneRenderer.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/dynamicZoneRenderer.tsx
index bde4c288fc7..1893f10b6ee 100644
--- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/dynamicZoneRenderer.tsx
+++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/dynamicZoneRenderer.tsx
@@ -30,6 +30,7 @@ export type DynamicZoneContainerProps = {
     title?: string;
     description?: string;
     className?: string;
+    actions?: JSX.Element;
 };
 
 export const DynamicZoneContainer = makeDecoratable(
@@ -57,6 +58,7 @@ export const DynamicZoneContainer = makeDecoratable(
                         description={description}
                         className={className || defaultClassName}
                         open={open}
+                        actions={props.actions ?? null}
                     >
                         {children}
                     </AccordionItem>

From 654ed2075d852b43693f03bf54b937de3f912fe9 Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Fri, 18 Apr 2025 11:06:51 +0200
Subject: [PATCH 51/52] fix(app-page-builder): use the correct composition
 scope name

---
 packages/app-page-builder/src/admin/plugins/routes.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/app-page-builder/src/admin/plugins/routes.tsx b/packages/app-page-builder/src/admin/plugins/routes.tsx
index dc3339deb89..40c77f28aad 100644
--- a/packages/app-page-builder/src/admin/plugins/routes.tsx
+++ b/packages/app-page-builder/src/admin/plugins/routes.tsx
@@ -71,7 +71,7 @@ const plugins: RoutePlugin[] = [
                         <RenderPluginsLoader>
                             <AdminLayout>
                                 <Helmet title={"Page Builder - Pages"} />
-                                <CompositionScope name={"pb.page"}>
+                                <CompositionScope name={"pb.pages"}>
                                     <Pages />
                                 </CompositionScope>
                             </AdminLayout>

From c291af090954e0155e0ab9eb4ba8dd40ddf0e57c Mon Sep 17 00:00:00 2001
From: Pavel Denisjuk <pavel@webiny.com>
Date: Fri, 18 Apr 2025 11:13:01 +0200
Subject: [PATCH 52/52] fix(app-page-builder): render null if element is not
 available

---
 .../ElementControlHorizontalDropZones.tsx                 | 8 +++-----
 1 file changed, 3 insertions(+), 5 deletions(-)

diff --git a/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/ElementControlHorizontalDropZones.tsx b/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/ElementControlHorizontalDropZones.tsx
index 48473ed9987..81a840e0f8c 100644
--- a/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/ElementControlHorizontalDropZones.tsx
+++ b/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/ElementControlHorizontalDropZones.tsx
@@ -89,8 +89,6 @@ export const ElementControlHorizontalDropZones = () => {
     const { showSnackbar } = useSnackbar();
     const parentType = meta.parentElement.type;
 
-    const { type } = element;
-
     const canDrop = (item: DragObjectWithTypeWithTarget) => {
         if (!item) {
             return false;
@@ -129,7 +127,7 @@ export const ElementControlHorizontalDropZones = () => {
         );
     };
 
-    if (!isDragging) {
+    if (!isDragging || !element) {
         return null;
     }
 
@@ -142,7 +140,7 @@ export const ElementControlHorizontalDropZones = () => {
             <Droppable
                 isVisible={({ item }) => canDrop(item)}
                 onDrop={source => dropElementAction(source, meta.elementIndex)}
-                type={type}
+                type={element.type}
             >
                 {({ drop, isOver }) => (
                     <WrapperDroppable ref={drop} below={false} zIndex={zIndex}>
@@ -156,7 +154,7 @@ export const ElementControlHorizontalDropZones = () => {
                 <Droppable
                     isVisible={({ item }) => canDrop(item)}
                     onDrop={source => dropElementAction(source, meta.elementIndex + 1)}
-                    type={type}
+                    type={element.type}
                 >
                     {({ drop, isOver }) => (
                         <WrapperDroppable ref={drop} below zIndex={zIndex}>