Skip to content

Commit

Permalink
prototype merge-on-save feature with session cookies
Browse files Browse the repository at this point in the history
  • Loading branch information
sdumetz committed Dec 4, 2023
1 parent 16ce2d8 commit 1907bad
Show file tree
Hide file tree
Showing 8 changed files with 295 additions and 12 deletions.
56 changes: 56 additions & 0 deletions source/server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions source/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
},
"dependencies": {
"body-parser": "^1.20.1",
"cookie-parser": "^1.4.6",
"cookie-session": "^2.0.0",
"express": "^4.17.1",
"express-rate-limit": "^7.1.2",
Expand All @@ -53,6 +54,7 @@
"devDependencies": {
"@types/chai": "^4.2.12",
"@types/chai-as-promised": "^7.1.5",
"@types/cookie-parser": "^1.4.6",
"@types/cookie-session": "^2.0.44",
"@types/express": "^4.17.14",
"@types/mocha": "^8.0.0",
Expand Down
5 changes: 4 additions & 1 deletion source/server/routes/scenes/get/document.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from "path";

import { getUserId, getVfs } from "../../../utils/locals.js";
import { Request, Response } from "express";
Expand All @@ -15,6 +16,8 @@ export default async function handleGetDocument(req :Request, res :Response){

let hash = createHash("sha256").update(f.data).digest("base64url");
let data = Buffer.from(f.data);
//Use this to know the client's document generation if he submits a change
res.cookie("docID", `${f.id}`, {sameSite: "strict", path: path.dirname(req.originalUrl)});

res.set("ETag", `W/${hash}`);
res.set("Last-Modified", f.mtime.toUTCString());
Expand All @@ -25,4 +28,4 @@ export default async function handleGetDocument(req :Request, res :Response){
res.set("Content-Type", "application/si-dpo-3d.document+json");
res.set("Content-Length", data.length.toString(10));
res.status(200).send(data);
};
};
8 changes: 6 additions & 2 deletions source/server/routes/scenes/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { NextFunction, Request, Response, Router } from "express";
import { Router } from "express";

import bodyParser from "body-parser";
import cookieParser from "cookie-parser";

import {handlePropfind} from "./propfind.js";
import {handlePutFile, handlePutDocument} from "./put/index.js";
import { canRead, canWrite, isAdministrator, isUser } from "../../utils/locals.js";
import wrap from "../../utils/wrapAsync.js";
import bodyParser from "body-parser";


import handleGetDocument from "./get/document.js";
import handleGetFile from "./get/file.js";
Expand Down Expand Up @@ -35,6 +38,7 @@ router.get("/:scene/:file(*.svx.json)", wrap(handleGetDocument));
router.put("/:scene/:file(*.svx.json)",
canWrite,
bodyParser.json({type:["application/si-dpo-3d.document+json", "application/json"]}),
cookieParser(),
wrap(handlePutDocument)
);
router.copy("/:scene/:file(*.svx.json)", canWrite, wrap(handleCopyDocument));
Expand Down
66 changes: 57 additions & 9 deletions source/server/routes/scenes/put/document.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,69 @@
import fs from "fs/promises";
import { constants } from "fs";

import path from "path";
import { AppLocals, getUserId, getVfs } from "../../../utils/locals.js";
import uid from "../../../utils/uid.js";
import { Request, Response } from "express";

import { getUserId, getVfs } from "../../../utils/locals.js";
import { BadRequestError } from "../../../utils/errors.js";

import * as merge from "../../../utils/merge.js";

/**
* Simple case of PUT /scenes/:scene/scene.svx.json
* Overwrites the current document.
* @param req
* @param res
* @returns
*/
async function overwritePutDocument(req: Request, res :Response){
const uid = getUserId(req);
const {scene} = req.params;
const newDoc = req.body;

console.log("Overwriting document");

let s = JSON.stringify(newDoc, null, 2);
if(s == "{}") throw new BadRequestError(`Invalid json document`);
const id = await getVfs(req).writeDoc(s, scene, uid);
res.cookie("docID", `${id}`, {sameSite: "strict", path: path.dirname(req.originalUrl)});
return res.status(204).send();
}


/**
* Special handler for svx files to disallow the upload of invalid JSON.
* @todo Should check against the official json schema using ajv
* If the user provides a reference document ID and the document has been updated since, a diff is performed to try to merge the changes.
*/
export default async function handlePutDocument(req :Request, res :Response){
const vfs = getVfs(req);
const uid = getUserId(req);
const {scene} = req.params;
let s = JSON.stringify(req.body, null, 2);
if(s == "{}") throw new BadRequestError(`Invalid json document`);
await vfs.writeDoc(s, scene, uid);
res.status(204).send();
const newDoc = req.body;

const refId = parseInt(req.cookies["docID"] ?? "0");
if(!refId) return await overwritePutDocument(req, res);


await getVfs(req).isolate(async (tr)=>{
// perform a diff of the document with the reference one
const {id: scene_id} = await tr.getScene(scene);
const {data: currentDocString} = await tr.getDoc(scene_id);
const currentDoc = JSON.parse(currentDocString);
const {data:refDocString} = await tr.getDocById(refId);
const refDoc = JSON.parse(refDocString);

const docDiff = merge.diff(refDoc, newDoc);
if(Object.keys(docDiff).length == 0){
console.log("Nothing to do");
//Nothing to do
return res.status(204).send();
}
console.log("Merge changes : ", JSON.stringify(docDiff));
const mergedDoc = merge.apply(currentDoc, docDiff);
let s = JSON.stringify(mergedDoc, null, 2);
if(s == "{}") throw new BadRequestError(`Invalid json document`);
let id = await tr.writeDoc(s, scene, uid);
res.cookie("docID", `${id}`, {sameSite: "strict", path: path.dirname(req.originalUrl)});
res.status(204).send();
})

};
102 changes: 102 additions & 0 deletions source/server/utils/merge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {DELETE_KEY, apply, diff} from "./merge.js";



describe("merge.diff()", function(){
describe("handle native data types ", function(){
[ //All JSON-serializable data types with possible bounday values
1,
0,
"1",
"",
true,
false,
null,
{},
{a:1},
[],
].forEach(v=>{
const v_clone :typeof v = JSON.parse(JSON.stringify(v));
it(`${JSON.stringify(v)} deep equals itself`, function(){
expect(diff({v}, {v:v_clone})).to.deep.equal({});
});
it(`${JSON.stringify({v})} compares from {}`, function(){
expect(diff({}, {v})).to.deep.equal({v:v_clone});
});
it(`${JSON.stringify({v})} compares from {}`, function(){
expect(diff({v}, {})).to.deep.equal({v:DELETE_KEY});
});
});
});

describe("arrays", function(){
//Arrays behave like objects with numeric keys, with some specific handling

it("handles an array equality", function(){
expect(diff({a:["foo"]}, {a:["foo"]})).to.deep.equal({});
});

it("handles nested objects equality", function(){
expect(diff({a:[{v: "foo"}]}, {a:[{v:"foo"}]})).to.deep.equal({});
});

it("handle array.push", function(){
expect(diff({a:[]}, {a:[1]})).to.deep.equal({a:{0:1}});
expect(diff({a:[1]}, {a:[1, 2]})).to.deep.equal({a:{1:2}});
});

it("handle Array.pop", function(){
expect(diff({a:[1]}, {a:[]})).to.deep.equal({a:{0: DELETE_KEY}});
expect(diff({a:[1, 2]}, {a:[1]})).to.deep.equal({a:{1: DELETE_KEY}});
});

it("handle Array.replace", function(){
expect(diff({a:["foo", "bar"]}, {a:["foo", "baz"]})).to.deep.equal({a:{1: "baz"}});
});

it("handle Array.splice", function(){
// Might be improved to detect a splice and use special syntax to represent it
expect(diff({a:["foo", "bar", "baz"]}, {a:["foo", "baz"]})).to.deep.equal({a:{1: "baz", 2: DELETE_KEY}});
});

it("throws when diffing an array with an object", function(){
expect(()=>diff({a:[]}, {a:{}})).to.throw("Can't diff an array with an object");
});


});
});


describe("merge.apply()", function(){
it("merges a no-op", function(){
const a = {a:1};
expect(apply(a, {})).to.deep.equal(a);
});

it("merges a deleted key", function(){
expect(apply({a:1, b:2}, {b:DELETE_KEY})).to.deep.equal({a:1});
});

it("merges an updated key", function(){
expect(apply({a:1, b:2}, {b:3})).to.deep.equal({a:1, b:3});
});
it("merges nested objects", function(){
expect(apply({a:{b:1}, c:3}, {a:{b:2}})).to.deep.equal({a:{b:2}, c:3});
});
it("merges updated arrays", function(){
expect(apply({a:[1,2]}, {a:{0:1, 1:3}})).to.deep.equal({a:[1,3]});
});
});


describe("three-way merge", function(){
// This is the real point of this module
it("string update", function(){
/** @TODO could try to do string splicing for finer results */
const ref = {greet:"Hello", name:"World"};
const current = {greet:"Hi", name:"World"};
const next = {greet:"Hello", name:"Universe"};
expect(apply(current, diff(ref, next))).to.deep.equal({greet:"Hi", name:"Universe"});
});
});
Loading

0 comments on commit 1907bad

Please sign in to comment.