Skip to content

Commit

Permalink
[WIP] stage history card
Browse files Browse the repository at this point in the history
  • Loading branch information
sdumetz committed Nov 29, 2024
1 parent 3fae697 commit af820f0
Show file tree
Hide file tree
Showing 10 changed files with 366 additions and 112 deletions.
24 changes: 23 additions & 1 deletion source/server/routes/history/get.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,29 @@ describe("GET /history/:scene", function(){
await vfs.createScene("empty", user.uid);
let res = await request(this.server).get("/history/empty")
.expect(200);
})
});

it("can ?limit results", async function(){
let res = await request(this.server).get("/history/foo?limit=1")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");

expect(res.body.map((i:any)=>([i.name, i.generation]))).to.deep.equal([
["scene.svx.json", 1],
]);
});

it("can ?offset results", async function(){
let res = await request(this.server).get("/history/foo?limit=1&offset=1")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");

expect(res.body.map((i:any)=>([i.name, i.generation]))).to.deep.equal([
["models", 1],
]);
});

describe("requires read access", function(){
this.beforeAll(async function(){
Expand Down
12 changes: 11 additions & 1 deletion source/server/routes/history/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,18 @@ import { getVfs } from "../../utils/locals.js";
export default async function getSceneHistory(req :Request, res :Response){
let vfs = getVfs(req);
let {scene:sceneName} = req.params;
let {
limit,
offset,
orderDirection,
} = req.query;

let scene = await vfs.getScene(sceneName);
let documents = await vfs.getSceneHistory(scene.id);
let documents = await vfs.getSceneHistory(scene.id, {
limit: limit? parseInt(limit as string): undefined,
offset: offset? parseInt(offset as string): undefined,
orderDirection: orderDirection as any,
});
res.format({
"application/json":()=>res.status(200).send(documents),
"text": ()=> res.status(200).send(documents.map(doc =>`${doc.name}#${doc.generation}`).join("\n")+"\n"),
Expand Down
4 changes: 2 additions & 2 deletions source/server/routes/history/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Request, Response } from "express";

import { BadRequestError } from "../../utils/errors.js";
import { getUser, getVfs } from "../../utils/locals.js";
import { ItemEntry } from "../../vfs/index.js";
import { HistoryEntry, ItemEntry } from "../../vfs/index.js";


/**
Expand All @@ -18,7 +18,7 @@ export async function postSceneHistory(req :Request, res :Response){
let requester = getUser(req);
let {scene:sceneName} = req.params;
let {name, generation } = req.body;
let files :Map<string, ItemEntry> = new Map();
let files :Map<string, HistoryEntry> = new Map();
if(!(typeof name === "string" && typeof generation === "number")){
throw new BadRequestError(`History restoration requires either of "name" and "generation" or "id" and "type" or "name" to be set`);
}
Expand Down
66 changes: 45 additions & 21 deletions source/server/vfs/Scenes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import config from "../utils/config.js";
import { BadRequestError, ConflictError, NotFoundError } from "../utils/errors.js";
import { Uid } from "../utils/uid.js";
import BaseVfs from "./Base.js";
import { ItemEntry, Scene, SceneQuery } from "./types.js";
import { HistoryEntry, ItemEntry, ItemProps, Scene, SceneQuery, Stored } from "./types.js";


export default abstract class ScenesVfs extends BaseVfs{
Expand Down Expand Up @@ -104,6 +104,34 @@ export default abstract class ScenesVfs extends BaseVfs{
) IN (${ AccessTypes.slice(AccessTypes.indexOf(accessMin)).map(s=>`'${s}'`).join(", ") })
`;
}

/**
* Performs a type and limit check on a SceneQuery object and throws if anything is unacceptable
* @param q
*/
static _parseSceneQuery(q :SceneQuery):SceneQuery{
//Check various parameters compliance
if(Array.isArray(q.access) && q.access.find(a=>AccessTypes.indexOf(a) === -1)){
throw new BadRequestError(`Bad access type requested : ${q.access.join(", ")}`);
}
if(typeof q.limit !== "undefined"){
if(typeof q.limit !="number" || Number.isNaN(q.limit) || !Number.isInteger(q.limit)) throw new BadRequestError(`When provided, limit must be an integer`);
if(q.limit <= 0) throw new BadRequestError(`When provided, limit must be >0`);
if(100 < q.limit) throw new BadRequestError(`When provided, limit must be <= 100`);
}
if(typeof q.offset !== "undefined"){
if(typeof q.offset !="number" || Number.isNaN(q.offset) || !Number.isInteger(q.offset)) throw new BadRequestError(`When provided, offset must be an integer`);
if(q.offset < 0) throw new BadRequestError(`When provided, limit must be >= 0`);
}

if(typeof q.orderDirection !== "undefined" && (typeof q.orderDirection !== "string" || ["asc", "desc"].indexOf(q.orderDirection.toLowerCase()) === -1)){
throw new BadRequestError(`Invalid orderDirection: ${q.orderDirection}`);
}
if(typeof q.orderBy !== "undefined" && (typeof q.orderBy !== "string" || ["ctime", "mtime", "name"].indexOf(q.orderBy.toLowerCase()) === -1)){
throw new BadRequestError(`Invalid orderBy: ${q.orderBy}`);
}
return q;
}

/**
* get all scenes, including archvied scenes Generally not used outside of tests and internal routines
Expand All @@ -117,19 +145,8 @@ export default abstract class ScenesVfs extends BaseVfs{
* Get only archived scenes.
*/
async getScenes(user_id:null, q :{access:["none"]}) :Promise<Scene[]>;
async getScenes(user_id ?:number|null, {access, match, limit =10, offset = 0, orderBy="name", orderDirection="asc"} :SceneQuery = {}) :Promise<Scene[]>{

//Check various parameters compliance
if(Array.isArray(access) && access.find(a=>AccessTypes.indexOf(a) === -1)){
throw new BadRequestError(`Bad access type requested : ${access.join(", ")}`);
}

if(typeof limit !="number" || Number.isNaN(limit) || limit < 0) throw new BadRequestError(`When provided, limit must be a number`);
if(typeof offset != "number" || Number.isNaN(offset) || offset < 0) throw new BadRequestError(`When provided, offset must be a number`);

if(["asc", "desc"].indexOf(orderDirection.toLowerCase()) === -1) throw new BadRequestError(`Invalid orderDirection: ${orderDirection}`);
if(["ctime", "mtime", "name"].indexOf(orderBy.toLowerCase()) === -1) throw new BadRequestError(`Invalid orderBy: ${orderBy}`);

async getScenes(user_id ?:number|null, q:SceneQuery = {}) :Promise<Scene[]>{
const {access, match, limit =10, offset = 0, orderBy="name", orderDirection="asc"} = ScenesVfs._parseSceneQuery(q);
let with_filter = typeof user_id === "number" || match || access?.length;

const sortString = (orderBy == "name")? "LOWER(scene_name)": orderBy;
Expand Down Expand Up @@ -235,7 +252,7 @@ export default abstract class ScenesVfs extends BaseVfs{
`, {
...mParams,
$user_id: (user_id? user_id.toString(10) : (access?.length? "0": undefined)),
$limit: Math.min(limit, 100),
$limit: limit,
$offset: offset,
})).map(({ctime, mtime, id, access, ...m})=>({
...m,
Expand Down Expand Up @@ -299,13 +316,15 @@ export default abstract class ScenesVfs extends BaseVfs{
* This could get quite large...
*
* Return order is **DESCENDING** over ctime, name, generation (so, new files first).
* Result is **NOT** access-dependant so it should only be returned for someone that has the required access level
*
* @warning It doesn't have any of the filters `listFiles` has.
* @todo handle size limit and pagination
* @see listFiles for a list of current files.
*/
async getSceneHistory(id :number) :Promise<Array<ItemEntry>>{
let entries = await this.db.all(`
async getSceneHistory(id :number, query:Pick<SceneQuery,"limit"|"offset"|"orderDirection"> ={}) :Promise<Array<HistoryEntry>>{
const {limit = 10, offset = 0, orderDirection = "desc"} = ScenesVfs._parseSceneQuery(query);

const dir = orderDirection.toUpperCase() as Uppercase<typeof orderDirection>;
let entries = await this.db.all<Omit<Stored<ItemEntry>,"mtime">[]>(`
SELECT name, mime, id, generation, ctime, username AS author, author_id, size
FROM(
SELECT
Expand All @@ -331,8 +350,13 @@ export default abstract class ScenesVfs extends BaseVfs{
WHERE fk_scene_id = $scene
)
INNER JOIN users ON author_id = user_id
ORDER BY ctime DESC, name DESC, generation DESC
`, {$scene: id});
ORDER BY ctime ${dir}, name ${dir}, generation ${dir}
LIMIT $offset, $limit
`, {
$scene: id,
$offset: offset,
$limit: limit,
});

return entries.map(m=>({
...m,
Expand Down
6 changes: 6 additions & 0 deletions source/server/vfs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface ItemProps{
id :number;
name :string;
}

export type Stored<T extends ItemProps> = Omit<T, "mtime"|"ctime"> & {mtime:string, ctime: string};

/** any item stored in a scene, with a name that identifies it */
Expand All @@ -45,6 +46,11 @@ export interface ItemEntry extends ItemProps{
mime :string;
}

/**
* Like `ItemEntry` but `mtime` is omitted because it doesn't make any sense in history context
*/
export type HistoryEntry = Pick<ItemEntry, "name"|"mime"|"id"|"generation"|"ctime"|"author_id"|"author"|"size">;

export interface FileProps extends ItemEntry{
/**sha254 base64 encoded string or null for deleted files */
hash :string|null;
Expand Down
117 changes: 92 additions & 25 deletions source/server/vfs/vfs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Uid } from "../utils/uid.js";
import UserManager from "../auth/UserManager.js";
import User from "../auth/User.js";
import { BadRequestError, ConflictError, NotFoundError } from "../utils/errors.js";
import ScenesVfs from "./Scenes.js";

async function *dataStream(src :Array<Buffer|string> =["foo", "\n"]){
for(let d of src){
Expand Down Expand Up @@ -45,6 +46,7 @@ describe("Vfs", function(){
await Vfs.Open(this.dir);
await expect(fs.access(path.join(this.dir, "uploads"))).to.be.fulfilled;
});

describe("isolate", function(){
it("can rollback on error", async function(){
let vfs = await Vfs.Open(this.dir);
Expand Down Expand Up @@ -72,6 +74,50 @@ describe("Vfs", function(){
});
});

describe("validate search params", function(){
it("accepts no parameters", function(){
expect(()=>ScenesVfs._parseSceneQuery({})).not.to.throw();
});
it("requires limit to be a positive integer", function(){
[null, "foo", 0.5, "0", 0, -1, 101].forEach((limit)=>{
expect(()=>ScenesVfs._parseSceneQuery({limit} as any), `{limit: ${limit}}`).to.throw();
});

[1, 10, 100].forEach((limit)=>{
expect(()=>ScenesVfs._parseSceneQuery({limit} as any)).not.to.throw();
});
});

it("requires offset to be a positive integer", function(){
[null, "foo", 0.5, "0", -1].forEach((offset)=>{
expect(()=>ScenesVfs._parseSceneQuery({offset} as any), `{offset: ${offset}}`).to.throw();
});

[0, 1, 10, 100, 1000].forEach((offset)=>{
expect(()=>ScenesVfs._parseSceneQuery({offset} as any)).not.to.throw();
});
});

it("requires orderDirection to match", function(){
["AS", "DE", null, 0, -1, 1, "1"].forEach((orderDirection)=>{
expect(()=>ScenesVfs._parseSceneQuery({orderDirection} as any), `{orderDirection: ${orderDirection}}`).to.throw("Invalid orderDirection");
});
["ASC", "DESC", "asc", "desc"].forEach((orderDirection)=>{
expect(()=>ScenesVfs._parseSceneQuery({orderDirection} as any)).not.to.throw();
})
});

it("requires orderBy to match", function(){
["foo", 1, -1, null].forEach((orderBy)=>{
expect(()=>ScenesVfs._parseSceneQuery({orderBy} as any), `{orderBy: ${orderBy}}`).to.throw(`Invalid orderBy`);
});

["ctime", "mtime", "name"].forEach((orderBy)=>{
expect(()=>ScenesVfs._parseSceneQuery({orderBy} as any), `{orderBy: "${orderBy}"}`).not.to.throw();
});
});
});

describe("", function(){
let vfs :Vfs;
//@ts-ignore
Expand Down Expand Up @@ -430,13 +476,8 @@ describe("Vfs", function(){
});

it("limits LIMIT to 100", async function(){
for(let i = 0; i < 110; i++){
await vfs.createScene(`scene_${i}`);
}
let res = await vfs.getScenes(0, {limit: 110, offset: 0})
expect(res).to.have.property("length", 100);
expect(res[0]).to.have.property("name", "scene_0");
})
await expect(vfs.getScenes(0, {limit: 110, offset: 0})).to.be.rejectedWith("[400]");
});
});
});

Expand Down Expand Up @@ -1020,25 +1061,51 @@ describe("Vfs", function(){

describe("getSceneHistory()", function(){
let default_folders = 2
it("get an ordered history of all writes to a scene", async function(){
let fileProps :WriteFileParams = {user_id: 0, scene:scene_id, mime: "model/gltf-binary", name:"models/foo.glb"}
await vfs.writeFile(dataStream(), fileProps);
await vfs.writeDoc("{}", scene_id, 0);
await vfs.writeFile(dataStream(), fileProps);
await vfs.writeDoc("{}", scene_id, 0);
let history = await vfs.getSceneHistory(scene_id);
expect(history).to.have.property("length", 4 + default_folders);
//Couln't easily test ctime sort
expect(history.map(e=>e.name)).to.deep.equal([
"scene.svx.json",
"scene.svx.json",
"models/foo.glb",
"models/foo.glb",
"models",
"articles",
]);
expect(history.map(e=>e.generation)).to.deep.equal([2,1,2,1,1,1]);
describe("get an ordered history", function(){
this.beforeEach(async function(){
let fileProps :WriteFileParams = {user_id: 0, scene:scene_id, mime: "model/gltf-binary", name:"models/foo.glb"}
await vfs.writeFile(dataStream(), fileProps);
await vfs.writeDoc("{}", scene_id, 0);
await vfs.writeFile(dataStream(), fileProps);
await vfs.writeDoc("{}", scene_id, 0);
});

it("all events", async function(){
let history = await vfs.getSceneHistory(scene_id);
expect(history).to.have.property("length", 4 + default_folders);
//Couln't easily test ctime sort
expect(history.map(e=>e.name)).to.deep.equal([
"scene.svx.json",
"scene.svx.json",
"models/foo.glb",
"models/foo.glb",
"models",
"articles",
]);
expect(history.map(e=>e.generation)).to.deep.equal([2,1,2,1,1,1]);
});

it("with limit", async function(){
let history = await vfs.getSceneHistory(scene_id, {limit: 1});
expect(history).to.have.property("length", 1);
//Couln't easily test ctime sort
expect(history.map(e=>e.name)).to.deep.equal([
"scene.svx.json",
]);
expect(history.map(e=>e.generation)).to.deep.equal([2]);
});
it("with offset", async function(){
let history = await vfs.getSceneHistory(scene_id, {limit: 2, offset: 1});
expect(history).to.have.property("length", 2);
//Couln't easily test ctime sort
expect(history.map(e=>e.name)).to.deep.equal([
"scene.svx.json",
"models/foo.glb",
]);
expect(history.map(e=>e.generation)).to.deep.equal([1,2]);
});
});

it("reports proper size for data strings", async function(){
//By default sqlite counts string length as char length and not byte length
let str = `{"id":"你好"}`;
Expand Down
Loading

0 comments on commit af820f0

Please sign in to comment.