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:
parent
3db018dcc4
commit
8a46155f91
1
api/.gitignore
vendored
1
api/.gitignore
vendored
@ -1,3 +1,2 @@
|
||||
.env
|
||||
db.json
|
||||
/uploads
|
||||
|
||||
@ -1,3 +1,2 @@
|
||||
package.json
|
||||
package-lock.json
|
||||
db.json
|
||||
|
||||
1
api/config/UPLOAD_DIRECTORY_URL.mjs
Normal file
1
api/config/UPLOAD_DIRECTORY_URL.mjs
Normal file
@ -0,0 +1 @@
|
||||
export default new URL('../uploads/', import.meta.url);
|
||||
30
api/package-lock.json
generated
30
api/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 it’s 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),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@ -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 doesn’t prevent storing the rest.
|
||||
const results = await Promise.allSettled(files.map(storeUpload));
|
||||
return results.reduce((storedFiles, { value, reason }) => {
|
||||
|
||||
@ -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),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@ -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` doesn’t 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
42
api/storeUpload.mjs
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user