Simplify the example by removing the DB.

This avoids trying to figure out how to fix concurrent writes conflicting with `lowdb` v2, see: https://github.com/typicode/lowdb/issues/478 .
This commit is contained in:
Jayden Seric 2021-06-11 17:17:58 +10:00
parent 3db018dcc4
commit 8a46155f91
11 changed files with 67 additions and 131 deletions

1
api/.gitignore vendored
View File

@ -1,3 +1,2 @@
.env
db.json
/uploads

View File

@ -1,3 +1,2 @@
package.json
package-lock.json
db.json

View File

@ -0,0 +1 @@
export default new URL('../uploads/', import.meta.url);

30
api/package-lock.json generated
View File

@ -12,7 +12,6 @@
"graphql": "^15.5.0",
"graphql-upload": "^12.0.0",
"koa": "^2.13.1",
"lowdb": "^2.1.0",
"make-dir": "^3.1.0",
"shortid": "^2.2.16"
},
@ -3251,17 +3250,6 @@
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="
},
"node_modules/lowdb": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lowdb/-/lowdb-2.1.0.tgz",
"integrity": "sha512-F4Go8/V37gAidTR3c5poyjprOpZSDNSLJVOmI0ny4D4q9rC37OkBhlzX0bqj7LZlT3UIj4FchmZrrSw7qY+eGQ==",
"dependencies": {
"steno": "^1.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/lowercase-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz",
@ -4397,11 +4385,6 @@
"node": ">= 0.6"
}
},
"node_modules/steno": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/steno/-/steno-1.0.0.tgz",
"integrity": "sha512-C/KgCvEa1yWnpHmaPjAXrz1yWxh6hs+HvhqqPa71euaQmNi1wr4+WFo57VQxjKKuFl2KqS7gtlrN0oxj2noQLw=="
},
"node_modules/streamsearch": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz",
@ -7530,14 +7513,6 @@
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="
},
"lowdb": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lowdb/-/lowdb-2.1.0.tgz",
"integrity": "sha512-F4Go8/V37gAidTR3c5poyjprOpZSDNSLJVOmI0ny4D4q9rC37OkBhlzX0bqj7LZlT3UIj4FchmZrrSw7qY+eGQ==",
"requires": {
"steno": "^1.0.0"
}
},
"lowercase-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz",
@ -8383,11 +8358,6 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
"integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow="
},
"steno": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/steno/-/steno-1.0.0.tgz",
"integrity": "sha512-C/KgCvEa1yWnpHmaPjAXrz1yWxh6hs+HvhqqPa71euaQmNi1wr4+WFo57VQxjKKuFl2KqS7gtlrN0oxj2noQLw=="
},
"streamsearch": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz",

View File

@ -25,7 +25,6 @@
"graphql": "^15.5.0",
"graphql-upload": "^12.0.0",
"koa": "^2.13.1",
"lowdb": "^2.1.0",
"make-dir": "^3.1.0",
"shortid": "^2.2.16"
},
@ -44,7 +43,7 @@
"test": "npm run test:eslint && npm run test:prettier",
"test:eslint": "eslint .",
"test:prettier": "prettier -c .",
"dev": "nodemon -i db.json",
"dev": "nodemon",
"start": "node -r dotenv/config server.mjs"
}
}

View File

@ -1,9 +1,5 @@
import {
GraphQLID,
GraphQLNonNull,
GraphQLObjectType,
GraphQLString,
} from 'graphql';
import { GraphQLNonNull, GraphQLObjectType, GraphQLString } from 'graphql';
import UPLOAD_DIRECTORY_URL from '../config/UPLOAD_DIRECTORY_URL.mjs';
export default new GraphQLObjectType({
name: 'File',
@ -11,19 +7,19 @@ export default new GraphQLObjectType({
fields: () => ({
id: {
description: 'Unique ID.',
type: GraphQLNonNull(GraphQLID),
},
path: {
description: 'Where its stored in the filesystem.',
type: GraphQLNonNull(GraphQLString),
resolve: (storedFileName) => storedFileName,
},
filename: {
description: 'Filename, including extension.',
name: {
description: 'File name.',
type: GraphQLNonNull(GraphQLString),
resolve: (storedFileName) => storedFileName,
},
mimetype: {
description: 'MIME type.',
url: {
description: 'File URL.',
type: GraphQLNonNull(GraphQLString),
resolve: (storedFileName) =>
new URL(storedFileName, UPLOAD_DIRECTORY_URL),
},
}),
});

View File

@ -1,5 +1,6 @@
import { GraphQLList, GraphQLNonNull, GraphQLObjectType } from 'graphql';
import { GraphQLUpload } from 'graphql-upload';
import storeUpload from '../storeUpload.mjs';
import FileType from './FileType.mjs';
export default new GraphQLObjectType({
@ -14,7 +15,7 @@ export default new GraphQLObjectType({
type: GraphQLNonNull(GraphQLUpload),
},
},
resolve: (parent, { file }, { storeUpload }) => storeUpload(file),
resolve: (parent, { file }) => storeUpload(file),
},
multipleUpload: {
description: 'Stores multiple files.',
@ -25,7 +26,7 @@ export default new GraphQLObjectType({
type: GraphQLNonNull(GraphQLList(GraphQLNonNull(GraphQLUpload))),
},
},
async resolve(parent, { files }, { storeUpload }) {
async resolve(parent, { files }) {
// Ensure an error storing one upload doesnt prevent storing the rest.
const results = await Promise.allSettled(files.map(storeUpload));
return results.reduce((storedFiles, { value, reason }) => {

View File

@ -1,4 +1,6 @@
import fs from 'fs';
import { GraphQLList, GraphQLNonNull, GraphQLObjectType } from 'graphql';
import UPLOAD_DIRECTORY_URL from '../config/UPLOAD_DIRECTORY_URL.mjs';
import FileType from './FileType.mjs';
export default new GraphQLObjectType({
@ -7,10 +9,7 @@ export default new GraphQLObjectType({
uploads: {
description: 'All stored files.',
type: GraphQLNonNull(GraphQLList(GraphQLNonNull(FileType))),
async resolve(source, args, { db }) {
await db.read();
return db.data.uploads;
},
resolve: () => fs.promises.readdir(UPLOAD_DIRECTORY_URL),
},
}),
});

View File

@ -1,65 +1,11 @@
import { createWriteStream, unlink } from 'fs';
import { fileURLToPath } from 'url';
import { ApolloServer } from 'apollo-server-koa';
import { graphqlUploadKoa } from 'graphql-upload';
import Koa from 'koa';
// `eslint-plugin-node` doesnt support the package `exports` field, see:
// https://github.com/mysticatea/eslint-plugin-node/issues/255
// eslint-disable-next-line node/no-missing-import
import { JSONFile, Low } from 'lowdb';
import makeDir from 'make-dir';
import shortId from 'shortid';
import UPLOAD_DIRECTORY_URL from './config/UPLOAD_DIRECTORY_URL.mjs';
import schema from './schema/index.mjs';
const UPLOAD_DIR = new URL('uploads/', import.meta.url);
const db = new Low(new JSONFile('db.json'));
/**
* Stores a GraphQL file upload. The file is stored in the filesystem and its
* metadata is recorded in the DB.
* @param {Promise<object>} upload GraphQL file upload.
* @returns {Promise<object>} File metadata.
*/
async function storeUpload(upload) {
const { createReadStream, filename, mimetype } = await upload;
const stream = createReadStream();
const id = shortId.generate();
const path = new URL(`${id}-${filename}`, UPLOAD_DIR);
const file = { id, filename, mimetype, path };
// Store the file in the filesystem.
await new Promise((resolve, reject) => {
// Create a stream to which the upload will be written.
const writeStream = createWriteStream(path);
// When the upload is fully written, resolve the promise.
writeStream.on('finish', resolve);
// If there's an error writing the file, remove the partially written file
// and reject the promise.
writeStream.on('error', (error) => {
unlink(path, () => {
reject(error);
});
});
// In Node.js <= v13, errors are not automatically propagated between piped
// streams. If there is an error receiving the upload, destroy the write
// stream with the corresponding error.
stream.on('error', (error) => writeStream.destroy(error));
// Pipe the upload into the write stream.
stream.pipe(writeStream);
});
// Record the file metadata in the DB.
await db.read();
db.data.uploads.push(file);
await db.write();
return file;
}
const app = new Koa().use(
graphqlUploadKoa({
// Limits here should be stricter than config for surrounding
@ -77,24 +23,14 @@ new ApolloServer({
// https://github.com/apollographql/apollo-server/issues/3508#issuecomment-662371289
uploads: false,
schema,
context: { db, storeUpload },
}).applyMiddleware({ app });
/**
* Starts the API server.
*/
async function startServer() {
await db.read();
// Seed an empty DB.
if (!db.data) {
db.data = { uploads: [] };
await db.write();
}
// Ensure upload directory exists.
await makeDir(fileURLToPath(UPLOAD_DIR));
// Ensure the upload directory exists.
await makeDir(fileURLToPath(UPLOAD_DIRECTORY_URL));
app.listen(process.env.PORT, (error) => {
if (error) throw error;

42
api/storeUpload.mjs Normal file
View File

@ -0,0 +1,42 @@
import { createWriteStream, unlink } from 'fs';
import shortId from 'shortid';
import UPLOAD_DIRECTORY_URL from './config/UPLOAD_DIRECTORY_URL.mjs';
/**
* Stores a GraphQL file upload in the filesystem.
* @param {Promise<object>} upload GraphQL file upload.
* @returns {Promise<string>} Resolves the stored file name.
*/
export default async function storeUpload(upload) {
const { createReadStream, filename } = await upload;
const stream = createReadStream();
const storedFileName = `${shortId.generate()}-${filename}`;
const storedFileUrl = new URL(storedFileName, UPLOAD_DIRECTORY_URL);
// Store the file in the filesystem.
await new Promise((resolve, reject) => {
// Create a stream to which the upload will be written.
const writeStream = createWriteStream(storedFileUrl);
// When the upload is fully written, resolve the promise.
writeStream.on('finish', resolve);
// If there's an error writing the file, remove the partially written file
// and reject the promise.
writeStream.on('error', (error) => {
unlink(storedFileUrl, () => {
reject(error);
});
});
// In Node.js <= v13, errors are not automatically propagated between piped
// streams. If there is an error receiving the upload, destroy the write
// stream with the corresponding error.
stream.on('error', (error) => writeStream.destroy(error));
// Pipe the upload into the write stream.
stream.pipe(writeStream);
});
return storedFileName;
}

View File

@ -6,9 +6,7 @@ const UPLOADS_QUERY = gql`
query uploads {
uploads {
id
filename
mimetype
path
url
}
}
`;
@ -21,17 +19,13 @@ export function Uploads() {
<Table>
<thead>
<tr>
<th>Filename</th>
<th>MIME type</th>
<th>Path</th>
<th>Stored file URL</th>
</tr>
</thead>
<tbody>
{uploads.map(({ id, filename, mimetype, path }) => (
{uploads.map(({ id, url }) => (
<tr key={id}>
<td>{filename}</td>
<td>{mimetype}</td>
<td>{path}</td>
<td>{url}</td>
</tr>
))}
</tbody>