Giant refactor

This commit is contained in:
Zef Hemel
2022-03-14 18:06:28 +01:00
parent 1984d8eefe
commit 9d41c9e3d6
22 changed files with 3268 additions and 435 deletions
+12
View File
@@ -0,0 +1,12 @@
export default {
extensionsToTreatAsEsm: [".ts"],
preset: "ts-jest/presets/default-esm", // or other ESM presets
globals: {
"ts-jest": {
useESM: true,
},
},
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
},
};
+14 -5
View File
@@ -4,25 +4,34 @@
"license": "MIT",
"source": "src/server.ts",
"main": "dist/server.js",
"type": "module",
"scripts": {
"build": "parcel build",
"watch": "parcel watch",
"start": "node dist/server.js",
"nodemon": "nodemon dist/server.js"
"watch": "parcel watch --no-cache",
"start": "node dist/server.js ../pages",
"nodemon": "nodemon dist/server.js ../pages",
"test": "jest"
},
"dependencies": {
"@codemirror/collab": "^0.19.0",
"@codemirror/state": "^0.19.9",
"@vscode/sqlite3": "^5.0.7",
"better-sqlite3": "^7.5.0",
"body-parser": "^1.19.2",
"cors": "^2.8.5",
"express": "^4.17.3",
"knex": "^1.0.4",
"socket.io": "^4.4.1",
"typescript": "^4.6.2"
"socket.io-client": "^4.4.1",
"typescript": "^4.6.2",
"yargs": "^17.3.1"
},
"devDependencies": {
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"jest": "^27.5.1",
"nodemon": "^2.0.15",
"parcel": "^2.3.2"
"parcel": "^2.3.2",
"ts-jest": "^27.1.3"
}
}
View File
+91
View File
@@ -0,0 +1,91 @@
import { test, expect, beforeAll, afterAll, describe } from "@jest/globals";
import { createServer } from "http";
import { io as Client } from "socket.io-client";
import { Server } from "socket.io";
import { SocketServer } from "./api";
import * as path from "path";
import * as fs from "fs";
describe("Server test", () => {
let io,
socketServer: SocketServer,
cleaner,
clientSocket,
reqId = 0;
const tmpDir = path.join(__dirname, "test");
function wsCall(eventName: string, ...args: any[]): Promise<any> {
return new Promise((resolve, reject) => {
reqId++;
clientSocket.once(`${eventName}Resp${reqId}`, (err, result) => {
if (err) {
reject(err);
} else {
resolve(result);
}
});
clientSocket.emit(eventName, reqId, ...args);
});
}
beforeAll((done) => {
const httpServer = createServer();
io = new Server(httpServer);
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(`${tmpDir}/test.md`, "This is a simple test");
httpServer.listen(async () => {
// @ts-ignore
const port = httpServer.address().port;
// @ts-ignore
clientSocket = new Client(`http://localhost:${port}`);
socketServer = new SocketServer(tmpDir, io);
clientSocket.on("connect", done);
});
});
afterAll(() => {
io.close();
clientSocket.close();
socketServer.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test("List pages", async () => {
let pages = await wsCall("listPages");
console.log(pages);
expect(pages.length).toBe(1);
});
test("Index operations", async () => {
await wsCall("index:clearPageIndexForPage", "test");
await wsCall("index:set", "test", "testkey", "value");
expect(await wsCall("index:get", "test", "testkey")).toBe("value");
await wsCall("index:delete", "test", "testkey");
expect(await wsCall("index:get", "test", "testkey")).toBe(null);
await wsCall("index:set", "test", "unrelated", 10);
await wsCall("index:set", "test", "unrelated", 12);
await wsCall("index:set", "test2", "complicated", {
name: "Bla",
age: 123123,
});
await wsCall("index:set", "test", "complicated", { name: "Bla", age: 100 });
await wsCall("index:set", "test", "complicated2", {
name: "Bla",
age: 101,
});
expect(await wsCall("index:get", "test", "complicated")).toStrictEqual({
name: "Bla",
age: 100,
});
let result = await wsCall("index:scanPrefixForPage", "test", "compli");
expect(result.length).toBe(2);
let result2 = await wsCall("index:scanPrefixGlobal", "compli");
expect(result2.length).toBe(3);
await wsCall("index:deletePrefixForPage", "test", "compli");
let result3 = await wsCall("index:scanPrefixForPage", "test", "compli");
expect(result3.length).toBe(0);
let result4 = await wsCall("index:scanPrefixGlobal", "compli");
expect(result4.length).toBe(1);
});
});
+346 -194
View File
@@ -1,229 +1,376 @@
import fs from "fs";
import { stat } from "fs/promises";
import path from "path";
import { ChangeSet } from "@codemirror/state";
import { Update } from "@codemirror/collab";
import { Server } from "socket.io";
import { Cursor, cursorEffect } from "../../webapp/src/cursorEffect";
import { Socket } from "socket.io";
import { DiskStorage } from "./disk_storage";
import { PageMeta } from "./server";
import { ClientPageState, Page } from "./types";
import { ClientPageState, Page, PageMeta } from "./types";
import { safeRun } from "./util";
import * as fs from "fs";
import * as path from "path";
import knex, { Knex } from "knex";
export function exposeSocketAPI(rootPath: string, io: Server) {
const openPages = new Map<string, Page>();
const connectedSockets: Set<Socket> = new Set();
const pageStore = new DiskStorage(rootPath);
fileWatcher(rootPath);
type IndexItem = {
page: string;
key: string;
value: any;
};
io.on("connection", (socket) => {
const socketOpenPages = new Set<string>();
class ClientConnection {
openPages = new Set<string>();
constructor(readonly sock: Socket) {}
}
console.log("Connected", socket.id);
connectedSockets.add(socket);
export class SocketServer {
rootPath: string;
serverSock: Server;
openPages = new Map<string, Page>();
connectedSockets = new Set<Socket>();
pageStore: DiskStorage;
db: Knex;
serverSocket: Server;
socket.on("disconnect", () => {
console.log("Disconnected", socket.id);
socketOpenPages.forEach(disconnectPageSocket);
connectedSockets.delete(socket);
});
socket.on("closePage", (pageName: string) => {
console.log("Closing page", pageName);
socketOpenPages.delete(pageName);
disconnectPageSocket(pageName);
});
const onCall = (
eventName: string,
cb: (...args: any[]) => Promise<any>
) => {
socket.on(eventName, (reqId: number, ...args) => {
cb(...args).then((result) => {
socket.emit(`${eventName}Resp${reqId}`, result);
});
});
};
const disconnectPageSocket = (pageName: string) => {
let page = openPages.get(pageName);
if (page) {
for (let client of page.clientStates) {
if (client.socket === socket) {
disconnectClient(client, page);
}
}
}
};
onCall("openPage", async (pageName: string) => {
let page = openPages.get(pageName);
api = {
openPage: async (clientConn: ClientConnection, pageName: string) => {
let page = this.openPages.get(pageName);
if (!page) {
try {
let { text, meta } = await pageStore.readPage(pageName);
let { text, meta } = await this.pageStore.readPage(pageName);
page = new Page(pageName, text, meta);
} catch (e) {
console.log("Creating new page", pageName);
page = new Page(pageName, "", { name: pageName, lastModified: 0 });
}
openPages.set(pageName, page);
this.openPages.set(pageName, page);
}
page.clientStates.add(new ClientPageState(socket, page.version));
socketOpenPages.add(pageName);
page.clientStates.add(new ClientPageState(clientConn.sock, page.version));
clientConn.openPages.add(pageName);
console.log("Opened page", pageName);
broadcastCursors(page);
this.broadcastCursors(page);
return page.toJSON();
});
},
pushUpdates: async (
clientConn: ClientConnection,
pageName: string,
version: number,
updates: any[]
): Promise<boolean> => {
let page = this.openPages.get(pageName);
onCall(
"pushUpdates",
async (
pageName: string,
version: number,
updates: any[]
): Promise<boolean> => {
let page = openPages.get(pageName);
if (!page) {
console.error(
"Received updates for not open page",
pageName,
openPages.keys()
);
return false;
}
if (version !== page.version) {
console.error("Invalid version", version, page.version);
return false;
} else {
console.log("Applying", updates.length, "updates to", pageName);
let transformedUpdates = [];
let textChanged = false;
for (let update of updates) {
let changes = ChangeSet.fromJSON(update.changes);
let transformedUpdate = {
changes,
clientID: update.clientID,
effects: update.cursors?.map((c: Cursor) => {
page.cursors.set(c.userId, c);
return cursorEffect.of(c);
}),
};
page.updates.push(transformedUpdate);
transformedUpdates.push(transformedUpdate);
let oldText = page.text;
page.text = changes.apply(page.text);
if (oldText !== page.text) {
textChanged = true;
}
}
if (textChanged) {
if (page.saveTimer) {
clearTimeout(page.saveTimer);
}
page.saveTimer = setTimeout(() => {
flushPageToDisk(pageName, page);
}, 1000);
}
while (page.pending.length) {
page.pending.pop()!(transformedUpdates);
}
return true;
}
if (!page) {
console.error(
"Received updates for not open page",
pageName,
this.openPages.keys()
);
return false;
}
);
onCall(
"pullUpdates",
async (pageName: string, version: number): Promise<Update[]> => {
let page = openPages.get(pageName);
// console.log("Pulling updates for", pageName);
if (!page) {
console.error("Fetching updates for not open page");
return [];
}
// TODO: Optimize this
let oldestVersion = Infinity;
page.clientStates.forEach((client) => {
oldestVersion = Math.min(client.version, oldestVersion);
if (client.socket === socket) {
client.version = version;
}
});
page.flushUpdates(oldestVersion);
if (version < page.version) {
return page.updatesSince(version);
} else {
return new Promise((resolve) => {
page.pending.push(resolve);
});
}
}
);
onCall(
"readPage",
async (pageName: string): Promise<{ text: string; meta: PageMeta }> => {
let page = openPages.get(pageName);
if (page) {
console.log("Serving page from memory", pageName);
return {
text: page.text.sliceString(0),
meta: page.meta,
if (version !== page.version) {
console.error("Invalid version", version, page.version);
return false;
} else {
console.log("Applying", updates.length, "updates to", pageName);
let transformedUpdates = [];
let textChanged = false;
for (let update of updates) {
let changes = ChangeSet.fromJSON(update.changes);
let transformedUpdate = {
changes,
clientID: update.clientID,
effects: update.cursors?.map((c: Cursor) => {
page.cursors.set(c.userId, c);
return cursorEffect.of(c);
}),
};
} else {
return pageStore.readPage(pageName);
page.updates.push(transformedUpdate);
transformedUpdates.push(transformedUpdate);
let oldText = page.text;
page.text = changes.apply(page.text);
if (oldText !== page.text) {
textChanged = true;
}
}
}
);
onCall("writePage", async (pageName: string, text: string) => {
let page = openPages.get(pageName);
if (textChanged) {
if (page.saveTimer) {
clearTimeout(page.saveTimer);
}
page.saveTimer = setTimeout(() => {
this.flushPageToDisk(pageName, page);
}, 1000);
}
while (page.pending.length) {
page.pending.pop()!(transformedUpdates);
}
return true;
}
},
pullUpdates: async (
clientConn: ClientConnection,
pageName: string,
version: number
): Promise<Update[]> => {
let page = this.openPages.get(pageName);
// console.log("Pulling updates for", pageName);
if (!page) {
console.error("Fetching updates for not open page");
return [];
}
// TODO: Optimize this
let oldestVersion = Infinity;
page.clientStates.forEach((client) => {
oldestVersion = Math.min(client.version, oldestVersion);
if (client.socket === clientConn.sock) {
client.version = version;
}
});
page.flushUpdates(oldestVersion);
if (version < page.version) {
return page.updatesSince(version);
} else {
return new Promise((resolve) => {
page.pending.push(resolve);
});
}
},
readPage: async (
clientConn: ClientConnection,
pageName: string
): Promise<{ text: string; meta: PageMeta }> => {
let page = this.openPages.get(pageName);
if (page) {
console.log("Serving page from memory", pageName);
return {
text: page.text.sliceString(0),
meta: page.meta,
};
} else {
return this.pageStore.readPage(pageName);
}
},
writePage: async (
clientConn: ClientConnection,
pageName: string,
text: string
) => {
let page = this.openPages.get(pageName);
if (page) {
for (let client of page.clientStates) {
client.socket.emit("reloadPage", pageName);
}
openPages.delete(pageName);
this.openPages.delete(pageName);
}
return pageStore.writePage(pageName, text);
});
return this.pageStore.writePage(pageName, text);
},
onCall("deletePage", async (pageName: string) => {
openPages.delete(pageName);
socketOpenPages.delete(pageName);
deletePage: async (clientConn: ClientConnection, pageName: string) => {
this.openPages.delete(pageName);
clientConn.openPages.delete(pageName);
// Cascading of this to all connected clients will be handled by file watcher
return pageStore.deletePage(pageName);
});
return this.pageStore.deletePage(pageName);
},
onCall("listPages", async (): Promise<PageMeta[]> => {
return pageStore.listPages();
});
listPages: async (clientConn: ClientConnection): Promise<PageMeta[]> => {
return this.pageStore.listPages();
},
onCall("getPageMeta", async (pageName: string): Promise<PageMeta> => {
let page = openPages.get(pageName);
getPageMeta: async (
clientConn: ClientConnection,
pageName: string
): Promise<PageMeta> => {
let page = this.openPages.get(pageName);
if (page) {
return page.meta;
}
return pageStore.getPageMeta(pageName);
});
});
return this.pageStore.getPageMeta(pageName);
},
function disconnectClient(client: ClientPageState, page: Page) {
page.clientStates.delete(client);
if (page.clientStates.size === 0) {
console.log("No more clients for", page.name, "flushing");
flushPageToDisk(page.name, page);
openPages.delete(page.name);
} else {
page.cursors.delete(client.socket.id);
broadcastCursors(page);
"index:clearPageIndexForPage": async (
clientConn: ClientConnection,
page: string
) => {
await this.db<IndexItem>("page_index").where({ page }).del();
},
"index:set": async (
clientConn: ClientConnection,
page: string,
key: string,
value: any
) => {
let changed = await this.db<IndexItem>("page_index")
.where({ page, key })
.update("value", JSON.stringify(value));
if (changed === 0) {
await this.db<IndexItem>("page_index").insert({
page,
key,
value: JSON.stringify(value),
});
}
},
"index:get": async (
clientConn: ClientConnection,
page: string,
key: string
) => {
let result = await this.db<IndexItem>("page_index")
.where({ page, key })
.select("value");
if (result.length) {
return JSON.parse(result[0].value);
} else {
return null;
}
},
"index:delete": async (
clientConn: ClientConnection,
page: string,
key: string
) => {
await this.db<IndexItem>("page_index").where({ page, key }).del();
},
"index:scanPrefixForPage": async (
clientConn: ClientConnection,
page: string,
prefix: string
) => {
return (
await this.db<IndexItem>("page_index")
.where({ page })
.andWhereLike("key", `${prefix}%`)
.select("page", "key", "value")
).map(({ page, key, value }) => ({
page,
key,
value: JSON.parse(value),
}));
},
"index:scanPrefixGlobal": async (
clientConn: ClientConnection,
prefix: string
) => {
return (
await this.db<IndexItem>("page_index")
.andWhereLike("key", `${prefix}%`)
.select("page", "key", "value")
).map(({ page, key, value }) => ({
page,
key,
value: JSON.parse(value),
}));
},
"index:deletePrefixForPage": async (
clientConn: ClientConnection,
page: string,
prefix: string
) => {
return await this.db<IndexItem>("page_index")
.where({ page })
.andWhereLike("key", `${prefix}%`)
.del();
},
"index:clearPageIndex": async (clientConn: ClientConnection) => {
return await this.db<IndexItem>("page_index").del();
},
};
constructor(rootPath: string, serverSocket: Server) {
this.rootPath = path.resolve(rootPath);
this.serverSocket = serverSocket;
this.pageStore = new DiskStorage(this.rootPath);
this.db = knex({
client: "better-sqlite3",
connection: {
filename: path.join(rootPath, "data.db"),
},
useNullAsDefault: true,
});
this.initDb();
serverSocket.on("connection", (socket) => {
const clientConn = new ClientConnection(socket);
// const socketOpenPages = new Set<string>();
console.log("Connected", socket.id);
this.connectedSockets.add(socket);
socket.on("disconnect", () => {
console.log("Disconnected", socket.id);
clientConn.openPages.forEach(disconnectPageSocket);
this.connectedSockets.delete(socket);
});
socket.on("closePage", (pageName: string) => {
console.log("Closing page", pageName);
clientConn.openPages.delete(pageName);
disconnectPageSocket(pageName);
});
const onCall = (
eventName: string,
cb: (...args: any[]) => Promise<any>
) => {
socket.on(eventName, (reqId: number, ...args) => {
cb(...args)
.then((result) => {
socket.emit(`${eventName}Resp${reqId}`, null, result);
})
.catch((err) => {
socket.emit(`${eventName}Resp${reqId}`, err.message);
});
});
};
const disconnectPageSocket = (pageName: string) => {
let page = this.openPages.get(pageName);
if (page) {
for (let client of page.clientStates) {
if (client.socket === socket) {
this.disconnectClient(client, page);
}
}
}
};
Object.entries(this.api).forEach(([eventName, cb]) => {
onCall(eventName, (...args: any[]): any => {
return cb.call(this, clientConn, ...args);
});
});
});
}
async initDb() {
if (!(await this.db.schema.hasTable("page_index"))) {
await this.db.schema.createTable("page_index", (table) => {
table.string("page");
table.string("key");
table.text("value");
table.primary(["page", "key"]);
});
console.log("Created table page_index");
}
}
function broadcastCursors(page: Page) {
disconnectClient(client: ClientPageState, page: Page) {
page.clientStates.delete(client);
if (page.clientStates.size === 0) {
console.log("No more clients for", page.name, "flushing");
this.flushPageToDisk(page.name, page);
this.openPages.delete(page.name);
} else {
page.cursors.delete(client.socket.id);
this.broadcastCursors(page);
}
}
broadcastCursors(page: Page) {
page.clientStates.forEach((client) => {
client.socket.emit(
"cursorSnapshot",
@@ -233,27 +380,27 @@ export function exposeSocketAPI(rootPath: string, io: Server) {
});
}
function flushPageToDisk(name: string, page: Page) {
flushPageToDisk(name: string, page: Page) {
safeRun(async () => {
let meta = await pageStore.writePage(name, page.text.sliceString(0));
let meta = await this.pageStore.writePage(name, page.text.sliceString(0));
console.log(`Wrote page ${name} to disk`);
page.meta = meta;
});
}
function fileWatcher(rootPath: string) {
fileWatcher() {
fs.watch(
rootPath,
this.rootPath,
{
recursive: true,
persistent: false,
},
(eventType, filename) => {
safeRun(async () => {
if (path.extname(filename) !== ".md") {
if (!filename.endsWith(".md")) {
return;
}
let localPath = path.join(rootPath, filename);
let localPath = path.join(this.rootPath, filename);
let pageName = filename.substring(0, filename.length - 3);
// console.log("Edit in", pageName, eventType);
let modifiedTime = 0;
@@ -263,16 +410,16 @@ export function exposeSocketAPI(rootPath: string, io: Server) {
} catch (e) {
// File was deleted
console.log("Deleted", pageName);
for (let socket of connectedSockets) {
for (let socket of this.connectedSockets) {
socket.emit("pageDeleted", pageName);
}
return;
}
const openPage = openPages.get(pageName);
const openPage = this.openPages.get(pageName);
if (openPage) {
if (openPage.meta.lastModified < modifiedTime) {
console.log("Page changed on disk outside of editor, reloading");
openPages.delete(pageName);
this.openPages.delete(pageName);
const meta = {
name: pageName,
lastModified: modifiedTime,
@@ -285,9 +432,10 @@ export function exposeSocketAPI(rootPath: string, io: Server) {
if (eventType === "rename") {
// This most likely means a new file was created, let's push new file listings to all connected sockets
console.log(
"New file created, broadcasting to all connected sockets"
"New file created, broadcasting to all connected sockets",
pageName
);
for (let socket of connectedSockets) {
for (let socket of this.connectedSockets) {
socket.emit("pageCreated", {
name: pageName,
lastModified: modifiedTime,
@@ -298,4 +446,8 @@ export function exposeSocketAPI(rootPath: string, io: Server) {
}
);
}
close() {
this.db.destroy();
}
}
+41 -27
View File
@@ -1,6 +1,6 @@
import { readdir, readFile, stat, unlink, writeFile } from "fs/promises";
import path from "path";
import { PageMeta, pagesPath } from "./server";
import * as path from "path";
import { PageMeta } from "./types";
export class DiskStorage {
rootPath: string;
@@ -37,41 +37,55 @@ export class DiskStorage {
}
async readPage(pageName: string): Promise<{ text: string; meta: PageMeta }> {
const localPath = path.join(pagesPath, pageName + ".md");
const s = await stat(localPath);
return {
text: await readFile(localPath, "utf8"),
meta: {
name: pageName,
lastModified: s.mtime.getTime(),
},
};
const localPath = path.join(this.rootPath, pageName + ".md");
try {
const s = await stat(localPath);
return {
text: await readFile(localPath, "utf8"),
meta: {
name: pageName,
lastModified: s.mtime.getTime(),
},
};
} catch (e) {
// console.error("Error while writing page", pageName, e);
throw Error(`Could not read page ${pageName}`);
}
}
async writePage(pageName: string, text: string): Promise<PageMeta> {
let localPath = path.join(pagesPath, pageName + ".md");
// await pipeline(body, fs.createWriteStream(localPath));
await writeFile(localPath, text);
let localPath = path.join(this.rootPath, pageName + ".md");
try {
await writeFile(localPath, text);
// console.log(`Wrote to ${localPath}`);
const s = await stat(localPath);
return {
name: pageName,
lastModified: s.mtime.getTime(),
};
// console.log(`Wrote to ${localPath}`);
const s = await stat(localPath);
return {
name: pageName,
lastModified: s.mtime.getTime(),
};
} catch (e) {
console.error("Error while writing page", pageName, e);
throw Error(`Could not write ${pageName}`);
}
}
async getPageMeta(pageName: string): Promise<PageMeta> {
let localPath = path.join(pagesPath, pageName + ".md");
const s = await stat(localPath);
return {
name: pageName,
lastModified: s.mtime.getTime(),
};
let localPath = path.join(this.rootPath, pageName + ".md");
try {
const s = await stat(localPath);
return {
name: pageName,
lastModified: s.mtime.getTime(),
};
} catch (e) {
console.error("Error while getting page meta", pageName, e);
throw Error(`Could not get meta for ${pageName}`);
}
}
async deletePage(pageName: string) {
let localPath = path.join(pagesPath, pageName + ".md");
let localPath = path.join(this.rootPath, pageName + ".md");
await unlink(localPath);
}
}
+16 -11
View File
@@ -2,7 +2,19 @@ import express from "express";
import { readFile } from "fs/promises";
import http from "http";
import { Server } from "socket.io";
import { exposeSocketAPI } from "./api";
import { SocketServer } from "./api";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
let args = yargs(hideBin(process.argv))
.option("debug", {
type: "boolean",
})
.option("port", {
type: "number",
default: 3000,
})
.parse();
const app = express();
const server = http.createServer(app);
@@ -13,18 +25,11 @@ const io = new Server(server, {
},
});
const port = 3000;
export const pagesPath = "../pages";
const port = args.port;
const distDir = `${__dirname}/../../webapp/dist`;
export type PageMeta = {
name: string;
lastModified: number;
version?: number;
};
app.use("/", express.static(distDir));
exposeSocketAPI(pagesPath, io);
let socketServer = new SocketServer(args._[0] as string, io);
// Fallback, serve index.html
let cachedIndex: string | undefined = undefined;
@@ -36,5 +41,5 @@ app.get("/*", async (req, res) => {
});
server.listen(port, () => {
console.log(`Server istening on port ${port}`);
console.log(`Server listening on port ${port}`);
});
+6 -2
View File
@@ -2,12 +2,16 @@ import { Update } from "@codemirror/collab";
import { Text } from "@codemirror/state";
import { Socket } from "socket.io";
import { Cursor } from "../../webapp/src/cursorEffect";
import { PageMeta } from "./server";
export class ClientPageState {
constructor(public socket: Socket, public version: number) {}
}
export type PageMeta = {
name: string;
lastModified: number;
version?: number;
};
export class Page {
versionOffset = 0;
updates: Update[] = [];
+2641 -39
View File
File diff suppressed because it is too large Load Diff