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
|
.env
|
||||||
db.json
|
|
||||||
/uploads
|
/uploads
|
||||||
|
|||||||
@ -1,3 +1,2 @@
|
|||||||
package.json
|
package.json
|
||||||
package-lock.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": "^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",
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 it’s 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),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 doesn’t prevent storing the rest.
|
// Ensure an error storing one upload doesn’t 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 }) => {
|
||||||
|
|||||||
@ -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;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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` 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 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
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 {
|
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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user