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 .env
db.json
/uploads /uploads

View File

@ -1,3 +1,2 @@
package.json package.json
package-lock.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": "^15.5.0",
"graphql-upload": "^12.0.0", "graphql-upload": "^12.0.0",
"koa": "^2.13.1", "koa": "^2.13.1",
"lowdb": "^2.1.0",
"make-dir": "^3.1.0", "make-dir": "^3.1.0",
"shortid": "^2.2.16" "shortid": "^2.2.16"
}, },
@ -3251,17 +3250,6 @@
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" "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": { "node_modules/lowercase-keys": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz",
@ -4397,11 +4385,6 @@
"node": ">= 0.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": { "node_modules/streamsearch": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", "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", "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" "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": { "lowercase-keys": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", "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", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
"integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" "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": { "streamsearch": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz",

View File

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

View File

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

View File

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

View File

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

View File

@ -1,65 +1,11 @@
import { createWriteStream, unlink } from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { ApolloServer } from 'apollo-server-koa'; import { ApolloServer } from 'apollo-server-koa';
import { graphqlUploadKoa } from 'graphql-upload'; import { graphqlUploadKoa } from 'graphql-upload';
import Koa from 'koa'; 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 makeDir from 'make-dir';
import shortId from 'shortid'; import UPLOAD_DIRECTORY_URL from './config/UPLOAD_DIRECTORY_URL.mjs';
import schema from './schema/index.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( const app = new Koa().use(
graphqlUploadKoa({ graphqlUploadKoa({
// Limits here should be stricter than config for surrounding // 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 // https://github.com/apollographql/apollo-server/issues/3508#issuecomment-662371289
uploads: false, uploads: false,
schema, schema,
context: { db, storeUpload },
}).applyMiddleware({ app }); }).applyMiddleware({ app });
/** /**
* Starts the API server. * Starts the API server.
*/ */
async function startServer() { async function startServer() {
await db.read(); // Ensure the upload directory exists.
await makeDir(fileURLToPath(UPLOAD_DIRECTORY_URL));
// Seed an empty DB.
if (!db.data) {
db.data = { uploads: [] };
await db.write();
}
// Ensure upload directory exists.
await makeDir(fileURLToPath(UPLOAD_DIR));
app.listen(process.env.PORT, (error) => { app.listen(process.env.PORT, (error) => {
if (error) throw 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 { query uploads {
uploads { uploads {
id id
filename url
mimetype
path
} }
} }
`; `;
@ -21,17 +19,13 @@ export function Uploads() {
<Table> <Table>
<thead> <thead>
<tr> <tr>
<th>Filename</th> <th>Stored file URL</th>
<th>MIME type</th>
<th>Path</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{uploads.map(({ id, filename, mimetype, path }) => ( {uploads.map(({ id, url }) => (
<tr key={id}> <tr key={id}>
<td>{filename}</td> <td>{url}</td>
<td>{mimetype}</td>
<td>{path}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>